diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md new file mode 100644 index 00000000..df6c8592 --- /dev/null +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -0,0 +1,406 @@ +--- +name: release-changelog-discord-message +description: > + Write the Discord release announcement for a stable Paperclip release. Companion + to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`; + this one turns that file into a single copy-pasteable Discord post in dotta's + voice and attaches it as the `discord_announcement` document on the release + issue. +--- + +# Release Discord Announcement Skill + +Write the Discord release announcement for the **stable** Paperclip release. + +This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill +generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into +a single copy-pasteable Discord block, in dotta's voice, and posts it as the +`discord_announcement` document on the release issue. + +## What dotta said + +> This is for discord — try to follow my format. If I have a section where I +> think about the future, pull from recent issues we're working on etc. + +The Discord announcement is **not** the changelog. The changelog is exhaustive; +the announcement is opinionated, in-voice, and built around the same handful of +shipped highlights plus a real "what's next" + "what's on my mind" pulled from +current Paperclip work — not invented. + +## When to use + +- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the + release worktree/PR. +- When the release issue (the one assigned by the release routine) asks for a + Discord announcement, or has a `discord_announcement` document that needs to + be refreshed for a new date/version. +- Never run this in isolation. The version, date, contributor list, and + highlight set MUST match the matching changelog file — if the changelog has + been updated, refresh this too. + +## Output + +A single fenced markdown code block, ready to paste into Discord. Attached as +issue document key `discord_announcement` on the release issue, and pasted +verbatim into a comment on that issue so the human can copy it out. + +```bash +PUT /api/issues/{releaseIssueId}/documents/discord_announcement +{ + "title": "Discord announcement", + "format": "markdown", + "body": "", + "baseRevisionId": "" +} +``` + +If the document already exists, fetch it first and pass the current +`baseRevisionId`. Never overwrite silently — if the version has changed since +the document was last written, mention what changed in the issue comment. + +## Format (follow this template) + +Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the +Unicode emoji. Discord renders the shortcodes; the changelog file uses prose. + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip: + +OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others + +## Highlights + +:emoji: **Feature Name** - one-sentence description in dotta's voice. +:emoji: **Feature Name** - … +:emoji: **Feature Name** - … + +... and a long tail of {flavor of the rest}. Read the [full release notes](). + +## WHATS NEXT (:motorway: Roadmap) + +* **Theme A** - one-line forward-looking blurb +* **Theme B** - … +* **Theme C** - … + +## What's on my mind + +* **Topic** - what's bugging dotta / what's queued / open questions +* **Topic** - … + +## PRESS (optional — only if there is real press) + +* **Outlet / Person** - what happened ([link]()) + +## WHAT I NEED FROM YOU (optional — only if there's a real ask) + +FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one +TELL ME if you're using Paperclip in your business - I want to meet you + +## Community + +Thank you to everyone who contributed to this release! + +``` +@username1, @username2, @username3 +``` + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a +thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md + +||@everyone|| +``` + +Notes on the template: + +- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are + part of the brand — keep them. +- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style + and stay consistent within a single post. +- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings + exactly once when the spoiler is removed by the poster. + +## Language tips + +These are extracted from how dotta has written the last several announcements. +Mimic this register; do not invent a "professional" tone. + +- **First person, conversational.** "I want to meet companies using Paperclip", + "what's on my mind", "if that's you let me know". Not "Paperclip is excited + to announce". +- **ALL CAPS for excitement and asks**, especially in the opener, the section + headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not + ALL-CAPS feature descriptions. +- **One emoji shortcode per highlight bullet**, picked to evoke the feature + (`:lock:` for secrets, `:brain:` for planning, `:mag:` for search, + `:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for + history/restore, `:thread:` for threads, etc.). +- **Highlight bullets are one sentence**, opinionated, told from the user's + perspective — "the cloud-secrets prereq is real now", not "added support + for…". +- **Tail line after highlights** wraps the rest in a single sentence and links + to the full release notes ("… and a long tail of {flavor}. Read the [full + release notes](url)."). +- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5 + bullets is the right size. Pull these from active goals, in-flight projects, + and recent issues the team is working on — do not invent themes. +- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps, + philosophical positioning ("we're the human control plane for ai labor"), + invitations ("if you've ever wanted to write about how you use Paperclip, + hit me up"). Pull real tensions from recent issues/comments; do not invent. +- **Press section** is optional. Only include it if there is real press in the + release window (a tweet, a podcast, a talk, a star milestone). No press → + drop the section entirely. +- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask + (follow the twitter, intros, beta sign-ups). No real ask → drop it. +- **Community** is the same contributors list that's in the changelog file, + fenced in a triple-backtick block, comma-separated `@username, @username`. + Exclude bots and Paperclip founders, same rules as the changelog skill. +- **The "In Summary" mission line** evolves slowly. Use the most recent + variant unless dotta tells you otherwise. Recent variants: + - "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK" + - "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY" + - "Paperclip will be _the_ control plane for AI agents in **every** company." +- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip: + :paperclip:`. Keep it. + +## Workflow + +1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`. + Use the version and contributor list from that file — never re-derive them. +2. Read the **release issue thread** (the one assigned to you that ran the + release routine) — comments + linked issues + recent issues in the company + are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes, + not invented ones. +3. Re-read the three verbatim examples below — they're the canonical voice. +4. Draft the announcement using the template above. +5. PUT it as the `discord_announcement` document on the release issue (see + "Output" above). If updating, send the latest `baseRevisionId`. +6. Post a comment on the release issue that includes the announcement inside a + single fenced markdown code block, so dotta can copy-paste it into Discord + without opening the document. + +Do not publish to Discord. This skill only prepares the artifact. + +## Verbatim previous examples + +Three previous Discord announcements from dotta, included **verbatim** as the +ground-truth examples for voice, structure, and emoji usage. When in doubt, +match these. + +### Example 1 — v2026.403.0 + +``` +CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip: + +## Highlights + +:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work +:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning +:page_with_curl: **Document revisions** - you can now restore old versions of your documents +:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables +:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings +:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy + +PLUS **tons** of improvements aound adapters, bugfixes, qol + +## COMMUNITY + +HUGE THANKS to the contributors with commits in this release: + +``` +@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai +``` + +## WHATS NEXT (ROADMAP) + +* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly +* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider + +PLUS: just dealing with the excellent PRs we have sitting in our inbox. + +**What's also on my mind (coming soonish)** + +* MAXIMIZER MODE - for when you've got a dream and tokens to burn +* Artifacts, work products, and deployments +* CEO Chat +* Stronger agent defaults + +## PRESS + +I've been doing my part to spread the word about Paperclip + +* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407) +* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345) +* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377) +* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613) +* ... and a couple others that will be released in a few days + +## SUCCESS STORIES + +* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing +* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment. +* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is. +* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it. +* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works. + +## IN SUMMARY + +I know there are still some rough edges, but + +Paperclip will be *the* control plane for AI agents in **every** company. + +and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES HERE + +https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0 + +||@everyone|| +``` + +### Example 2 — v2026.416.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip: + +## Highlights + +This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer + +:thread: Issue chat threads now are a conversation more than comments +:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task) +:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip +:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work + +And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0) + +## COMMUNITY + +INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release: + +``` +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6 +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Multi-human users** - in the last stages of testing, Paperclip is better with teams +* **Memory Infrastructure** - your agents will remember everything about yoru business +* **Sandbox execution** - run your agents anywhere + +## What's on my mind + +* I want to meet with companies who are using Paperclip in their business - if that's you let me know +* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already! +* We still need to get better at reviewing your PRs and we're improving our process every day +* "Zero-human company" language has to go - we're the human control plane for ai labor +* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon. + +## PRESS + +* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931) +* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) ! + +## IN SUMMARY + +PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY + +If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0 + +||@everyone|| +``` + +### Example 3 — v2026.427.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip: + +THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping + +## Highlights + +:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance +:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you +:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning +:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads +:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers +... and *tons* of other improvements and bugfixes. + +## Community + +Thank you to everyone who contributed to this release! + +``` +@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done +* **Secrets Service v2** - an important prereq for Paperclip cloud +* **Artifacts, memory, and knowledge** +* **Conference Room** aka CEO/Agent Chat + +## What's on my mind + +* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these +* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed +* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing +* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team + +## Press + +I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998) + +## WHAT I NEED FROM YOU + +FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md + +||@everyone|| +``` + +## Review checklist + +Before handing off: + +1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly. +2. Contributor list matches the changelog (same exclusions: bots, founders). +3. Highlights are a subset of the changelog Highlights — same shipped features, + not invented or pre-alpha work. +4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues / + active goals — not invented themes. +5. Section style (UPPERCASE vs Title Case) is internally consistent. +6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:` + and `||@everyone||` is the very last line. +7. Document `discord_announcement` is updated on the release issue, and the + announcement is also posted in a comment inside a fenced code block. + +This skill never posts to Discord. It only prepares the announcement artifact. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fabaab1b..fa081796 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,7 +62,8 @@ jobs: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile fi - verify: + typecheck_release_registry: + name: Typecheck + Release Registry needs: [policy] runs-on: ubuntu-latest timeout-minutes: 20 @@ -88,12 +89,89 @@ jobs: - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps - - name: Run general test suites - run: pnpm test:run:general - - name: Verify release registry test coverage run: pnpm run test:release-registry + general_tests: + name: General tests (${{ matrix.group_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - group: general-server + group_label: server + - group: general-workspaces-a + group_label: workspaces-a + - group: general-workspaces-b + group_label: workspaces-b + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run grouped general test suites + run: pnpm test:run:general -- --group '${{ matrix.group }}' + + verify: + # Preserve the legacy required-check name while the underlying work runs in parallel. + name: verify + if: ${{ always() }} + needs: [typecheck_release_registry, general_tests, build] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Fail if any split verify lane failed + env: + TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }} + GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success" + test "$GENERAL_TESTS_RESULT" = "success" + test "$BUILD_RESULT" = "success" + + build: + name: Build + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build run: pnpm build diff --git a/.gitignore b/.gitignore index b9578487..2d8d5454 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,6 @@ tests/e2e/playwright-report/ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ .superset/ +.superpowers/ .claude/worktrees/ +.herenow diff --git a/Dockerfile b/Dockerfile index e367910e..03f26942 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ +COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ @@ -35,7 +36,9 @@ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ +COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/ COPY patches/ patches/ +COPY scripts/link-plugin-dev-sdk.mjs scripts/ RUN pnpm install --frozen-lockfile diff --git a/cli/README.md b/cli/README.md index b23c32a6..8aa2cc16 100644 --- a/cli/README.md +++ b/cli/README.md @@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
+## Paperclip Cloud Sync + +Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing. + +```bash +paperclipai cloud connect https://your-stack.paperclip.app +paperclipai cloud connect https://your-stack.paperclip.app --no-browser +paperclipai cloud push --company --dry-run +paperclipai cloud push --company +``` + +`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code. + +`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events. + ## Development ```bash diff --git a/cli/package.json b/cli/package.json index 2dffbeee..b60dde4d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -43,6 +43,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts new file mode 100644 index 00000000..c66fd33f --- /dev/null +++ b/cli/src/__tests__/cloud.test.ts @@ -0,0 +1,243 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; +import { + assertDiscoveryCompatible, + buildBundleFromLocalCompany, + cloudCommandExitCodes, + connectCloud, + resolveDeviceCodeExpiresAt, +} from "../commands/client/cloud.js"; +import { + LocalUpstreamPushCoordinator, + normalizedContentHash, + type LocalUpstreamExportBundle, +} from "../commands/client/cloud-transfer.js"; +import { getCloudConnection } from "../commands/client/cloud-store.js"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; + +describe("cloud CLI helpers", () => { + let tempHome: string; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-")); + process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it("connects with the device-code flow and stores the resulting cloud connection", async () => { + globalThis.fetch = vi.fn(async (url, init) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/.well-known/paperclip-upstream")) { + return jsonResponse(discovery()); + } + if (requestUrl.endsWith("/api/upstream-sync/device-code")) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + stackId: "stack-1", + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }); + return jsonResponse({ + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + intervalSeconds: 0, + }); + } + if (requestUrl.endsWith("/api/upstream-sync/token")) { + return jsonResponse({ + accessToken: "upt_test", + scopes: ["upstream_import:preview"], + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }); + } + return jsonResponse({ error: "not_found" }, 404); + }) as typeof fetch; + + const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true }); + + expect(connection.accessToken).toBe("upt_test"); + expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1"); + }); + + it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => { + expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i); + expect(cloudCommandExitCodes.schemaMismatch).toBe(3); + }); + + it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => { + const now = Date.UTC(2026, 4, 22, 13, 0, 0); + const validExpiry = "2026-05-22T13:05:00.000Z"; + + expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry)); + expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000); + expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000); + }); + + it("builds deterministic chunks with validated payload hashes", async () => { + const bundle = await buildTestBundle(); + + expect(bundle.chunks).toHaveLength(2); + expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload)); + expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash); + expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey); + }); + + it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => { + const bundle = await buildTestBundle(); + const calls: Array<{ path: string; body: unknown }> = []; + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: "https://cloud.example.test", + paperclipCompanyId: "target-company-1", + fetch: async (url, init) => { + const parsed = new URL(String(url)); + const body = init?.body ? JSON.parse(String(init.body)) as unknown : {}; + calls.push({ path: parsed.pathname, body }); + if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } }); + return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } }); + }, + }); + + await coordinator.apply(bundle); + await coordinator.apply(bundle); + + const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } }); + const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string }); + expect(runBodies).toHaveLength(2); + expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey); + expect(chunkBodies[0]).toEqual(chunkBodies[2]); + expect(chunkBodies[1]).toEqual(chunkBodies[3]); + }); +}); + +async function buildTestBundle(): Promise { + return buildBundleFromLocalCompany({ + localCompanyId: "local-company-1", + connection: { + id: "conn-1", + remoteUrl: "https://cloud.example.test", + targetOrigin: "https://cloud.example.test", + targetHost: "cloud.example.test", + stackId: "stack-1", + targetCompanyId: "target-company-1", + accessToken: "upt_test", + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + privateKeyPem: "unused", + sourcePublicKey: "unused", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }, + discovery: discovery(), + localApi: { + post: async () => portabilityExport() as T, + }, + maxEntitiesPerChunk: 1, + mode: "apply", + }); +} + +function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) { + return { + schema: "paperclip-upstream-discovery-v1", + stack: { + id: "stack-1", + slug: "cloud-test", + displayName: "Cloud Test", + companyId: "target-company-1", + origin: "https://cloud.example.test", + }, + auth: { + deviceCode: { + deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code", + verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve", + tokenUrl: "https://cloud.example.test/api/upstream-sync/token", + }, + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }, + transfer: { + supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1, + featureFlags: ["cloud_sync"], + }, + }; +} + +function portabilityExport(): CompanyPortabilityExportResult { + return { + rootPath: ".", + paperclipExtensionPath: ".paperclip.yaml", + manifest: { + schemaVersion: 1, + generatedAt: "2026-05-18T00:00:00.000Z", + source: { + companyId: "local-company-1", + companyName: "Local Company", + }, + includes: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + company: { + path: "company.json", + name: "Local Company", + description: null, + brandColor: null, + logoPath: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }, + sidebar: null, + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + }, + files: { + "README.md": "Local Company", + }, + warnings: [], + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts index d75452ab..513e3853 100644 --- a/cli/src/__tests__/network-bind.test.ts +++ b/cli/src/__tests__/network-bind.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared"; import { buildPresetServerConfig } from "../config/server-bind.js"; +const ORIGINAL_PATH = process.env.PATH; + describe("network bind helpers", () => { it("rejects non-loopback bind modes in local_trusted", () => { expect( @@ -50,13 +52,18 @@ describe("network bind helpers", () => { it("falls back to loopback when no tailscale address is available for tailnet presets", () => { delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + process.env.PATH = ""; - const preset = buildPresetServerConfig("tailnet", { - port: 3100, - allowedHostnames: [], - serveUi: true, - }); + try { + const preset = buildPresetServerConfig("tailnet", { + port: 3100, + allowedHostnames: [], + serveUi: true, + }); - expect(preset.server.host).toBe("127.0.0.1"); + expect(preset.server.host).toBe("127.0.0.1"); + } finally { + process.env.PATH = ORIGINAL_PATH; + } }); }); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index 0c694f4b..b7c01eb5 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -7,6 +7,7 @@ import type { PaperclipConfig } from "../config/schema.js"; const ORIGINAL_ENV = { ...process.env }; const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_PATH = process.env.PATH; function createExistingConfigFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); @@ -171,8 +172,13 @@ describe("onboard", () => { it("keeps tailnet quickstart on loopback until tailscale is available", async () => { const configPath = createFreshConfigPath(); delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + process.env.PATH = ""; - await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + try { + await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + } finally { + process.env.PATH = ORIGINAL_PATH; + } const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; expect(raw.server.deploymentMode).toBe("authenticated"); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index afb83f73..49c445b1 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -512,6 +512,45 @@ describe("worktree helpers", () => { } }); + it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + const repoConfigDir = path.join(repoRoot, ".paperclip"); + fs.mkdirSync(repoConfigDir, { recursive: true }); + fs.writeFileSync(path.join(repoConfigDir, "config.json"), "stale", "utf8"); + fs.writeFileSync(path.join(repoConfigDir, ".env"), "STALE=1", "utf8"); + + // Simulate the repo-managed worktrees subfolder that holds every + // worktree checkout (the directory PAPA-358 reported as nuked). + const worktreesDir = path.join(repoConfigDir, "worktrees"); + const checkoutDir = path.join(worktreesDir, "PAP-100-feature"); + fs.mkdirSync(checkoutDir, { recursive: true }); + const sentinelPath = path.join(checkoutDir, "sentinel.txt"); + fs.writeFileSync(sentinelPath, "do-not-delete", "utf8"); + + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + force: true, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(sentinelPath)).toBe(true); + expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do-not-delete"); + expect(fs.existsSync(path.join(repoConfigDir, "config.json"))).toBe(true); + expect(fs.readFileSync(path.join(repoConfigDir, "config.json"), "utf8")).not.toBe("stale"); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + itEmbeddedPostgres( "seeds authenticated users into minimally cloned worktree instances", async () => { diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index d7d16f17..31dfe0d0 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; +import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printGeminiStreamEvent, }; +const grokLocalCLIAdapter: CLIAdapterModule = { + type: "grok_local", + formatStdoutEvent: printGrokStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -66,6 +72,7 @@ const adaptersByType = new Map( cursorLocalCLIAdapter, cursorCloudCLIAdapter, geminiLocalCLIAdapter, + grokLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/cli/src/commands/client/cloud-store.ts b/cli/src/commands/client/cloud-store.ts new file mode 100644 index 00000000..fa63c713 --- /dev/null +++ b/cli/src/commands/client/cloud-store.ts @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "../../config/home.js"; + +export interface CloudConnectionTokenRecord { + id: string; + companyStackId: string; + targetOrigin: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + expiresAt: string; + [key: string]: unknown; +} + +export interface CloudConnection { + id: string; + remoteUrl: string; + targetOrigin: string; + targetHost: string; + stackId: string; + stackSlug?: string | null; + stackDisplayName?: string | null; + targetCompanyId: string; + accessToken: string; + token: CloudConnectionTokenRecord; + privateKeyPem: string; + sourcePublicKey: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + createdAt: string; + updatedAt: string; +} + +interface CloudConnectionStore { + version: 1; + connections: Record; + currentConnectionId?: string; +} + +function defaultStore(): CloudConnectionStore { + return { + version: 1, + connections: {}, + }; +} + +export function resolveCloudConnectionStorePath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json"); +} + +export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore { + if (!fs.existsSync(storePath)) return defaultStore(); + const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial | null; + const connections: Record = {}; + if (raw?.connections && typeof raw.connections === "object") { + for (const [id, value] of Object.entries(raw.connections)) { + const normalized = normalizeConnection(value); + if (normalized) connections[id] = normalized; + } + } + const currentConnectionId = + typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId] + ? raw.currentConnectionId + : Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id; + return { + version: 1, + connections, + currentConnectionId, + }; +} + +export function writeCloudConnectionStore( + store: CloudConnectionStore, + storePath = resolveCloudConnectionStorePath(), +): void { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); +} + +export function upsertCloudConnection( + connection: CloudConnection, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection { + const store = readCloudConnectionStore(storePath); + const existing = store.connections[connection.id]; + const now = new Date().toISOString(); + const next = { + ...connection, + createdAt: existing?.createdAt ?? connection.createdAt ?? now, + updatedAt: now, + }; + store.connections[next.id] = next; + store.currentConnectionId = next.id; + writeCloudConnectionStore(store, storePath); + return next; +} + +export function getCloudConnection( + remoteUrlOrOrigin?: string, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection | null { + const store = readCloudConnectionStore(storePath); + if (remoteUrlOrOrigin?.trim()) { + const needle = normalizeRemoteLookup(remoteUrlOrOrigin); + return Object.values(store.connections).find((connection) => + normalizeRemoteLookup(connection.remoteUrl) === needle || + normalizeRemoteLookup(connection.targetOrigin) === needle + ) ?? null; + } + return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null; +} + +function normalizeRemoteLookup(value: string): string { + try { + const url = new URL(value); + return url.origin.replace(/\/+$/u, ""); + } catch { + return value.trim().replace(/\/+$/u, ""); + } +} + +function normalizeConnection(value: unknown): CloudConnection | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + const id = stringValue(record.id); + const remoteUrl = stringValue(record.remoteUrl); + const targetOrigin = stringValue(record.targetOrigin); + const targetHost = stringValue(record.targetHost); + const stackId = stringValue(record.stackId); + const targetCompanyId = stringValue(record.targetCompanyId); + const accessToken = stringValue(record.accessToken); + const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token) + ? record.token as CloudConnectionTokenRecord + : null; + const privateKeyPem = stringValue(record.privateKeyPem); + const sourcePublicKey = stringValue(record.sourcePublicKey); + const sourceInstanceId = stringValue(record.sourceInstanceId); + const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint); + const createdAt = stringValue(record.createdAt); + const updatedAt = stringValue(record.updatedAt); + if ( + !id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId || + !accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId || + !sourceInstanceFingerprint || !createdAt || !updatedAt + ) { + return null; + } + return { + id, + remoteUrl, + targetOrigin, + targetHost, + stackId, + stackSlug: stringValue(record.stackSlug), + stackDisplayName: stringValue(record.stackDisplayName), + targetCompanyId, + accessToken, + token, + privateKeyPem, + sourcePublicKey, + sourceInstanceId, + sourceInstanceFingerprint, + scopes: stringArray(record.scopes), + createdAt, + updatedAt, + }; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts new file mode 100644 index 00000000..9cd1ccbe --- /dev/null +++ b/cli/src/commands/client/cloud-transfer.ts @@ -0,0 +1,297 @@ +import { createHash } from "node:crypto"; + +export const upstreamTransferSchema = { + family: "paperclip-upstream-transfer", + version: "1.0.0", + major: 1, + minor: 0, +} as const; + +export type NormalizedSha256 = `sha256:${string}`; + +export interface SourceEntityKey { + sourceInstanceId: string; + sourceCompanyId: string; + sourceEntityType: string; + sourceEntityId: string; + sourceNaturalKey?: string; +} + +export interface UpstreamTransferWarning { + code: string; + severity: "info" | "warning" | "blocker"; + message: string; + entity?: SourceEntityKey; +} + +export interface UpstreamTransferEntityRecord { + key: SourceEntityKey; + contentHash: NormalizedSha256; + dependencies: SourceEntityKey[]; + warnings: UpstreamTransferWarning[]; +} + +export interface UpstreamTransferManifestSource { + sourceInstanceId: string; + sourceCompanyId: string; + sourceInstanceKeyFingerprint: string; + exporterVersion: string; + sourceSchemaVersion: string; +} + +export interface UpstreamTransferManifestTarget { + targetStackId: string; + targetCompanyId: string; + targetOrigin: string; + supportedSchemaMajor: number; +} + +export interface UpstreamTransferChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + manifestHash: NormalizedSha256; +} + +export interface UpstreamTransferManifest { + schema: typeof upstreamTransferSchema; + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + generatedAt: string; + entityCount: number; + entities: UpstreamTransferEntityRecord[]; + chunks: UpstreamTransferChunk[]; + warnings: UpstreamTransferWarning[]; + featureFlags: string[]; + manifestHash: NormalizedSha256; +} + +export interface LocalUpstreamExportEntityInput { + key: SourceEntityKey; + body: Record; + dependencies?: SourceEntityKey[]; + warnings?: UpstreamTransferWarning[]; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportEntity { + record: UpstreamTransferEntityRecord; + body: Record; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + payload: { + entityKeys: SourceEntityKey[]; + }; +} + +export interface LocalUpstreamExportBundle { + manifest: UpstreamTransferManifest; + entities: LocalUpstreamExportEntity[]; + chunks: LocalUpstreamExportChunk[]; +} + +export interface BuildLocalUpstreamExportBundleInput { + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + entities: LocalUpstreamExportEntityInput[]; + warnings?: UpstreamTransferWarning[]; + featureFlags?: string[]; + maxEntitiesPerChunk?: number; +} + +export interface LocalUpstreamPushCoordinatorOptions { + targetOrigin: string; + paperclipCompanyId: string; + fetch?: typeof fetch; + headers?: (input: { method: string; path: string }) => HeadersInit | Promise; +} + +export class UpstreamImportRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export class LocalUpstreamPushCoordinator { + readonly #targetOrigin: string; + readonly #paperclipCompanyId: string; + readonly #fetch: typeof fetch; + readonly #headers: NonNullable; + + constructor(options: LocalUpstreamPushCoordinatorOptions) { + this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, ""); + this.#paperclipCompanyId = options.paperclipCompanyId; + this.#fetch = options.fetch ?? fetch; + this.#headers = options.headers ?? (() => ({})); + } + + async preview(bundle: LocalUpstreamExportBundle): Promise { + return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, { + manifest: bundle.manifest, + entities: bundle.entities, + }); + } + + async apply(bundle: LocalUpstreamExportBundle): Promise { + const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, { + mode: "apply", + manifest: bundle.manifest, + entities: bundle.entities, + }) as { run?: { id?: unknown } }; + const runId = typeof run.run?.id === "string" ? run.run.id : undefined; + if (!runId) { + throw new Error("Remote upstream importer did not return a run id"); + } + + for (const chunk of bundle.chunks) { + await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk); + } + + return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {}); + } + + async events(runId: string): Promise { + return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`); + } + + private async get(path: string): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "GET", + headers: await this.#headers({ method: "GET", path }), + }); + return parseCoordinatorResponse(response); + } + + private async post(path: string, body: unknown): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(await this.#headers({ method: "POST", path })), + }, + body: JSON.stringify(body), + }); + return parseCoordinatorResponse(response); + } +} + +export function buildLocalUpstreamExportBundle( + input: BuildLocalUpstreamExportBundleInput, +): LocalUpstreamExportBundle { + const entities = input.entities.map((entity) => ({ + record: { + key: entity.key, + contentHash: normalizedContentHash(entity.body), + dependencies: entity.dependencies ?? [], + warnings: entity.warnings ?? [], + }, + body: entity.body, + conflictKeys: entity.conflictKeys, + })); + const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100); + const manifestWithoutHash = { + schema: upstreamTransferSchema, + source: input.source, + target: input.target, + runId: input.runId, + idempotencyKey: input.idempotencyKey, + generatedAt: new Date(0).toISOString(), + entityCount: entities.length, + entities: entities.map((entity) => entity.record), + chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk), + warnings: input.warnings ?? [], + featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(), + }; + const manifestHash = normalizedContentHash(manifestWithoutHash); + return { + manifest: { + ...manifestWithoutHash, + chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })), + manifestHash, + }, + entities, + chunks, + }; +} + +export function normalizedContentHash(value: unknown): NormalizedSha256 { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function buildLocalChunks( + entities: LocalUpstreamExportEntity[], + maxEntitiesPerChunk: number, +): LocalUpstreamExportChunk[] { + if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { + throw new Error("maxEntitiesPerChunk must be a positive integer"); + } + if (entities.length === 0) return []; + + const groups: LocalUpstreamExportEntity[][] = []; + for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) { + groups.push(entities.slice(index, index + maxEntitiesPerChunk)); + } + + return groups.map((group, index) => { + const payload = { + entityKeys: group.map((entity) => entity.record.key), + }; + return { + chunkIndex: index, + totalChunks: groups.length, + byteLength: Buffer.byteLength(canonicalJson(payload)), + sha256: normalizedContentHash(payload), + payload, + }; + }); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +async function parseCoordinatorResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeParseJson(text) : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Upstream importer request failed with ${response.status}`; + throw new UpstreamImportRequestError(response.status, message, parsed); + } + return parsed; +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts new file mode 100644 index 00000000..f87098a0 --- /dev/null +++ b/cli/src/commands/client/cloud.ts @@ -0,0 +1,721 @@ +import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { URL } from "node:url"; +import { Command } from "commander"; +import pc from "picocolors"; +import type { + CompanyPortabilityExportResult, + CompanyPortabilityFileEntry, + InstanceExperimentalSettings, +} from "@paperclipai/shared"; +import { openUrl } from "../../client/board-auth.js"; +import { resolvePaperclipInstanceId } from "../../config/home.js"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; +import { + buildLocalUpstreamExportBundle, + LocalUpstreamPushCoordinator, + normalizedContentHash, + upstreamTransferSchema, + UpstreamImportRequestError, + type LocalUpstreamExportBundle, + type LocalUpstreamExportEntityInput, + type SourceEntityKey, + type UpstreamTransferManifestSource, + type UpstreamTransferManifestTarget, + type UpstreamTransferWarning, +} from "./cloud-transfer.js"; +import { + getCloudConnection, + upsertCloudConnection, + type CloudConnection, + type CloudConnectionTokenRecord, +} from "./cloud-store.js"; + +const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2; +const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3; +const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; +const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000; + +interface CloudConnectOptions extends BaseClientOptions { + noBrowser?: boolean; +} + +interface CloudPushOptions extends BaseClientOptions { + company?: string; + remoteUrl?: string; + dryRun?: boolean; + maxEntitiesPerChunk?: number; +} + +interface UpstreamDiscovery { + schema: string; + stack: { + id: string; + slug?: string; + displayName?: string; + companyId: string; + origin: string; + }; + auth: { + pkce?: { + authorizeUrl: string; + tokenUrl: string; + codeChallengeMethod: string; + }; + deviceCode?: { + deviceCodeUrl: string; + verificationUrl: string; + tokenUrl: string; + }; + scopes?: string[]; + }; + transfer: { + supportedSchemaMajor: number; + featureFlags?: string[]; + }; +} + +interface TokenResponse { + accessToken: string; + token: CloudConnectionTokenRecord; + scopes?: string[]; + expiresAt?: string; +} + +class CloudAuthRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export function registerCloudCommands(program: Command): void { + const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands"); + + addCommonClientOptions( + cloud + .command("connect") + .description("Authorize this local instance to push into a Paperclip Cloud stack") + .argument("", "Paperclip Cloud stack URL") + .option("--no-browser", "Use the device-code flow instead of opening a browser", false) + .action(async (remoteUrl: string, opts: CloudConnectOptions) => { + try { + await connectCloud(remoteUrl, opts); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + cloud + .command("push") + .description("Preview or apply a local company push into the connected Paperclip Cloud stack") + .requiredOption("--company ", "Local company ID to export") + .option("--remote-url ", "Use a specific stored cloud connection") + .option("--dry-run", "Preview without applying", false) + .option("--max-entities-per-chunk ", "Chunk size for upstream uploads", (value) => Number(value), 100) + .action(async (opts: CloudPushOptions) => { + try { + await pushCloud(opts); + } catch (err) { + if (isSchemaMismatchError(err)) { + console.error(pc.red(err instanceof Error ? err.message : String(err))); + process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE; + return; + } + handleCommandError(err); + } + }), + ); +} + +export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise { + const ctx = resolveCommandContext(opts); + const discovery = await discoverUpstream(remoteUrl); + assertDiscoveryCompatible(discovery); + const source = createSourceIdentity(); + const token = await authorizeConnection(discovery, source, { + noBrowser: Boolean(opts.noBrowser), + }); + const targetOrigin = discovery.stack.origin.replace(/\/+$/u, ""); + const targetHost = new URL(targetOrigin).host; + const now = new Date().toISOString(); + const connection = upsertCloudConnection({ + id: connectionId(targetOrigin), + remoteUrl, + targetOrigin, + targetHost, + stackId: discovery.stack.id, + stackSlug: discovery.stack.slug ?? null, + stackDisplayName: discovery.stack.displayName ?? null, + targetCompanyId: discovery.stack.companyId, + accessToken: token.accessToken, + token: token.token, + privateKeyPem: source.privateKeyPem, + sourcePublicKey: source.sourcePublicKey, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES, + createdAt: now, + updatedAt: now, + }); + + if (ctx.json) { + printOutput(redactConnection(connection), { json: true }); + } else { + console.log(pc.bold("Connected to Paperclip Cloud")); + console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`); + console.log(`origin=${connection.targetOrigin}`); + console.log(`company=${connection.targetCompanyId}`); + } + return connection; +} + +export async function pushCloud(opts: CloudPushOptions): Promise { + const ctx = resolveCommandContext(opts, { requireCompany: false }); + const localCompanyId = requiredString(opts.company, "--company"); + await assertCloudSyncEnabled(ctx.api.get("/api/instance/settings/experimental")); + const connection = getCloudConnection(opts.remoteUrl); + if (!connection) { + throw new Error("No cloud connection found. Run `paperclipai cloud connect ` first."); + } + + const discovery = await discoverUpstream(connection.targetOrigin); + assertDiscoveryCompatible(discovery); + const bundle = await buildBundleFromLocalCompany({ + localCompanyId, + connection, + discovery, + localApi: ctx.api, + maxEntitiesPerChunk: opts.maxEntitiesPerChunk, + mode: opts.dryRun ? "preview" : "apply", + }); + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: connection.targetOrigin, + paperclipCompanyId: connection.targetCompanyId, + headers: ({ method, path }) => cloudProofHeaders(connection, method, path), + }); + + const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle); + const runId = getRunId(result); + const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null; + const summary = summarizeResult(result); + const conflictCount = summary.conflict + summary.staleMapping; + + if (ctx.json) { + printOutput({ result, events }, { json: true }); + } else { + console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied")); + console.log(`run=${runId ?? "-"}`); + console.log(`manifest=${bundle.manifest.manifestHash}`); + console.log( + `create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` + + `skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`, + ); + printWarnings(result); + printConflicts(result); + printEvents(events); + } + + if (conflictCount > 0) { + process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE; + } + return result; +} + +export async function discoverUpstream(remoteUrl: string): Promise { + const base = new URL(remoteUrl); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", base); + return requestCloudJson(discoveryUrl.toString(), { method: "GET" }); +} + +export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void { + if (discovery.schema !== "paperclip-upstream-discovery-v1") { + throw new Error("Remote URL is not a Paperclip Cloud upstream target."); + } + if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) { + throw new Error( + `Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`, + ); + } + if (!discovery.transfer.featureFlags?.includes("cloud_sync")) { + throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag."); + } +} + +export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number { + const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN; + return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS; +} + +export async function buildBundleFromLocalCompany(input: { + localCompanyId: string; + connection: CloudConnection; + discovery: UpstreamDiscovery; + localApi: { + post(path: string, body?: unknown): Promise; + }; + maxEntitiesPerChunk?: number; + mode: "preview" | "apply"; +}): Promise { + const exported = await input.localApi.post( + `/api/companies/${input.localCompanyId}/export`, + { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + expandReferencedSkills: true, + }, + ); + if (!exported) throw new Error("Local company export returned no data."); + + const sourceHash = normalizedContentHash({ + manifest: exported.manifest, + files: exported.files, + }); + const source: UpstreamTransferManifestSource = { + sourceInstanceId: input.connection.sourceInstanceId, + sourceCompanyId: input.localCompanyId, + sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint, + exporterVersion: "paperclipai-cli-cloud-v1", + sourceSchemaVersion: "paperclip-local-portability-v1", + }; + const target: UpstreamTransferManifestTarget = { + targetStackId: input.discovery.stack.id, + targetCompanyId: input.discovery.stack.companyId, + targetOrigin: input.discovery.stack.origin, + supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor, + }; + const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported); + const idempotencyKey = [ + input.mode, + input.connection.sourceInstanceId, + input.localCompanyId, + input.discovery.stack.id, + sourceHash, + ].join(":"); + return buildLocalUpstreamExportBundle({ + source, + target, + runId: `local-${input.mode}-${shortHash(idempotencyKey)}`, + idempotencyKey, + entities, + warnings: exported.warnings.map((message): UpstreamTransferWarning => ({ + code: "local_company_export_warning", + severity: "warning", + message, + })), + featureFlags: ["cloud_sync"], + maxEntitiesPerChunk: input.maxEntitiesPerChunk, + }); +} + +async function authorizeConnection( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { noBrowser: boolean }, +): Promise { + if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) { + try { + return await authorizeWithBrowser(discovery, source); + } catch (error) { + console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`)); + } + } + if (!discovery.auth.deviceCode) { + throw new Error("Remote Paperclip Cloud stack does not support device-code authorization."); + } + return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() }); +} + +async function authorizeWithBrowser( + discovery: UpstreamDiscovery, + source: ReturnType, +): Promise { + const pkce = discovery.auth.pkce; + if (!pkce) throw new Error("Remote did not advertise PKCE authorization."); + const callback = await startPkceCallbackServer(); + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomUUID(); + const authorizeUrl = new URL(pkce.authorizeUrl); + authorizeUrl.searchParams.set("redirectUri", callback.redirectUri); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("codeChallenge", challenge); + authorizeUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId); + authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint); + authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey); + authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" ")); + + try { + console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`); + if (!openUrl(authorizeUrl.toString())) { + throw new Error("Could not open a browser."); + } + const code = await callback.waitForCode(state); + return requestCloudJson(pkce.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "authorization_code", + code, + redirectUri: callback.redirectUri, + codeVerifier: verifier, + }), + }); + } finally { + await callback.close(); + } +} + +async function authorizeWithDeviceCode( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { openBrowser: boolean }, +): Promise { + const device = discovery.auth.deviceCode; + if (!device) throw new Error("Remote did not advertise device-code authorization."); + const response = await requestCloudJson<{ + deviceCode: string; + userCode: string; + verificationUri: string; + expiresAt?: string; + intervalSeconds?: number; + }>(device.deviceCodeUrl, { + method: "POST", + body: JSON.stringify({ + stackId: discovery.stack.id, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + sourcePublicKey: source.sourcePublicKey, + scopes: CLOUD_SYNC_SCOPES, + }), + }); + console.error(pc.bold("Cloud device authorization required")); + console.error(`Open: ${response.verificationUri}`); + console.error(`Code: ${response.userCode}`); + if (opts.openBrowser) openUrl(response.verificationUri); + + const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt); + const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000); + while (Date.now() < expiresAt) { + await sleep(intervalMs); + try { + return await requestCloudJson(device.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "device_code", + deviceCode: response.deviceCode, + }), + }); + } catch (error) { + if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") { + const code = (error.body as { error?: unknown }).error; + if (code === "authorization_pending") continue; + } + throw error; + } + } + throw new Error("Device-code authorization expired before it was approved."); +} + +function buildEntitiesFromPortableExport( + localCompanyId: string, + sourceInstanceId: string, + exported: CompanyPortabilityExportResult, +): LocalUpstreamExportEntityInput[] { + const companyKey: SourceEntityKey = { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company", + sourceEntityId: localCompanyId, + sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId, + }; + const entities: LocalUpstreamExportEntityInput[] = [ + { + key: companyKey, + body: { + kind: "paperclip_company_portability_manifest", + manifest: exported.manifest, + rootPath: exported.rootPath, + paperclipExtensionPath: exported.paperclipExtensionPath, + fileCount: Object.keys(exported.files).length, + }, + conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`], + }, + ]; + + for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) { + entities.push({ + key: { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company_setting", + sourceEntityId: shortHash(filePath), + sourceNaturalKey: filePath, + }, + body: { + kind: "paperclip_portable_file", + path: filePath, + entry: normalizePortableFileEntry(entry), + }, + dependencies: [companyKey], + conflictKeys: [`portable_file:${filePath}`], + }); + } + return entities; +} + +function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record { + if (typeof entry === "string") { + return { encoding: "utf8", data: entry }; + } + return { ...entry }; +} + +async function assertCloudSyncEnabled(settingsPromise: Promise): Promise { + const settings = await settingsPromise; + if (settings?.enableCloudSync !== true) { + throw new Error( + "Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.", + ); + } +} + +function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record { + const timestamp = new Date().toISOString(); + const nonce = randomUUID(); + const payload = [ + method, + connection.targetHost.toLowerCase(), + pathAndSearch, + connection.token.id, + connection.sourceInstanceId, + timestamp, + nonce, + ].join("\n"); + return { + Authorization: `Bearer ${connection.accessToken}`, + "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId, + "X-Paperclip-Upstream-Proof-Timestamp": timestamp, + "X-Paperclip-Upstream-Proof-Nonce": nonce, + "X-Paperclip-Upstream-Proof-Signature": sign( + null, + Buffer.from(payload, "utf8"), + connection.privateKeyPem, + ).toString("base64url"), + }; +} + +async function requestCloudJson(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body !== undefined && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetch(url, { ...init, headers }); + const text = await response.text(); + const parsed = text.trim() ? JSON.parse(text) as unknown : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Cloud request failed with ${response.status}`; + throw new CloudAuthRequestError(response.status, message, parsed); + } + return parsed as T; +} + +function createSourceIdentity() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; + return { + sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`, + sourceInstanceFingerprint, + sourcePublicKey, + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +async function startPkceCallbackServer(): Promise<{ + redirectUri: string; + waitForCode: (state: string) => Promise; + close: () => Promise; +}> { + let resolveCode: ((code: string) => void) | null = null; + let rejectCode: ((error: Error) => void) | null = null; + let expectedState = ""; + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization failed. You can close this tab."); + rejectCode?.(new Error("Authorization callback was missing a valid code or state.")); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization complete. You can close this tab."); + resolveCode?.(code); + }); + await listenOnLoopback(server); + const address = server.address(); + if (typeof address !== "object" || !address?.port) { + throw new Error("Failed to start local authorization callback server."); + } + return { + redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`, + waitForCode: (state: string) => { + expectedState = state; + return codePromise; + }, + close: () => closeServer(server), + }; +} + +function listenOnLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +function canOpenBrowser(): boolean { + if (process.platform === "darwin" || process.platform === "win32") return true; + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + +function summarizeResult(result: unknown): { + create: number; + update: number; + adopt: number; + skip: number; + conflict: number; + staleMapping: number; +} { + const summary = asRecord(asRecord(result)?.summary); + return { + create: numberValue(summary?.create), + update: numberValue(summary?.update), + adopt: numberValue(summary?.adopt), + skip: numberValue(summary?.skip), + conflict: numberValue(summary?.conflict), + staleMapping: numberValue(summary?.staleMapping), + }; +} + +function printWarnings(result: unknown): void { + const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : []; + for (const warning of warnings) { + const record = asRecord(warning); + console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim())); + } +} + +function printConflicts(result: unknown): void { + const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : []; + for (const conflict of conflicts.slice(0, 10)) { + const record = asRecord(conflict); + console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`)); + } + if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`)); +} + +function printEvents(events: unknown): void { + const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : []; + for (const row of rows.slice(-10)) { + const event = asRecord(row); + console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`)); + } +} + +function getRunId(result: unknown): string | null { + const run = asRecord(asRecord(result)?.run); + return typeof run?.id === "string" ? run.id : null; +} + +function redactConnection(connection: CloudConnection): Record { + return { + id: connection.id, + remoteUrl: connection.remoteUrl, + targetOrigin: connection.targetOrigin, + stackId: connection.stackId, + targetCompanyId: connection.targetCompanyId, + scopes: connection.scopes, + expiresAt: connection.token.expiresAt, + }; +} + +function connectionId(targetOrigin: string): string { + return `cloud-${shortHash(targetOrigin)}`; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value === "string" && value.trim()) return value.trim(); + throw new Error(`${label} is required.`); +} + +function numberValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function isSchemaMismatchError(error: unknown): boolean { + if (error instanceof UpstreamImportRequestError) { + return JSON.stringify(error.body).toLowerCase().includes("schema"); + } + return error instanceof Error && error.message.toLowerCase().includes("schema mismatch"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const cloudCommandExitCodes = { + conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE, + schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE, +} as const; diff --git a/cli/src/commands/routines.ts b/cli/src/commands/routines.ts index 4c5a3ca9..63ff89d8 100644 --- a/cli/src/commands/routines.ts +++ b/cli/src/commands/routines.ts @@ -9,6 +9,7 @@ import { createEmbeddedPostgresLogBuffer, ensurePostgresDatabase, formatEmbeddedPostgresError, + prepareEmbeddedPostgresNativeRuntime, routines, } from "@paperclipai/db"; import { eq, inArray } from "drizzle-orm"; @@ -116,6 +117,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", ); } + await prepareEmbeddedPostgresNativeRuntime(); const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const runningPid = readRunningPostmasterPid(postmasterPidFile); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index faa8e490..db0ca008 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -45,6 +45,7 @@ import { runDatabaseRestore, createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError, + prepareEmbeddedPostgresNativeRuntime, } from "@paperclipai/db"; import type { Command } from "commander"; import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; @@ -1059,6 +1060,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", ); } + await prepareEmbeddedPostgresNativeRuntime(); const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const runningPid = readRunningPostmasterPid(postmasterPidFile); @@ -1385,7 +1387,12 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { } if (opts.force) { - rmSync(paths.repoConfigDir, { recursive: true, force: true }); + // Only remove the specific files we're about to rewrite, not the whole + // repoConfigDir — that directory can contain sibling state such as + // /.paperclip/worktrees/ holding every repo-managed worktree + // checkout, and a recursive rmSync here would nuke them all. + rmSync(paths.configPath, { force: true }); + rmSync(paths.envPath, { force: true }); rmSync(paths.instanceRoot, { recursive: true, force: true }); } diff --git a/cli/src/index.ts b/cli/src/index.ts index f1a2084a..b9fd1159 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,6 +19,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { registerRoutineCommands } from "./commands/routines.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js"; import { registerSecretCommands } from "./commands/client/secrets.js"; +import { registerCloudCommands } from "./commands/client/cloud.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; @@ -149,6 +150,7 @@ registerDashboardCommands(program); registerRoutineCommands(program); registerFeedbackCommands(program); registerSecretCommands(program); +registerCloudCommands(program); registerWorktreeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program); diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2e0ad661..43dfc05a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -143,6 +143,17 @@ The database mode is controlled by `DATABASE_URL`: Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode. +## Resource membership tables + +Paperclip stores current-user sidebar membership state in: + +- `project_memberships` +- `agent_memberships` + +These rows are company-scoped and user-scoped. A missing row means the user is joined, so existing users keep seeing projects and agents in the sidebar until they explicitly leave them. Rows only control sidebar visibility; they do not affect project/agent detail access, all-pages, selectors, assignment flows, or existing company permissions. + +Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes. + ## Plugin database namespaces The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present. @@ -165,6 +176,10 @@ Paperclip stores secret metadata and versions in: - `company_secrets` - `company_secret_versions` +- `company_secret_bindings` +- `secret_access_events` + +Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`. For local/default installs, the active provider is `local_encrypted`: diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f41465e2..341f3b11 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -554,10 +554,12 @@ pnpm paperclipai dashboard get See full command reference in `doc/CLI.md`. -## OpenClaw Invite Onboarding Endpoints +## Agent Invite Onboarding Endpoints Agent-oriented invite onboarding now exposes machine-readable API docs: +The board UI generates agent onboarding prompts from the add-agent modal (`+` in the agent sidebar), so agent onboarding sits with the rest of agent creation rather than company member invite settings. + - `GET /api/invites/:token` returns invite summary plus onboarding and skills index links. - `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints). - `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates. @@ -575,7 +577,7 @@ pnpm smoke:openclaw-join What it validates: - invite creation for agent-only join -- agent join request using `adapterType=openclaw` +- agent join request using `adapterType=openclaw_gateway` - board approval + one-time API key claim semantics - callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index f955e677..7a3d1f7b 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**, - Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. - Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. - Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane. +- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. - Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. - Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 45f2e15b..5c5af615 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1. | Company model | Company is first-order; all business entities are company-scoped | | Board | Single human board operator per deployment | | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Full visibility to board and all agents in same company | +| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) | @@ -207,6 +207,8 @@ Invariant: - project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected +Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. + ## 7.6 `issues` (core task entity) - `id` uuid pk @@ -309,7 +311,32 @@ Invariant: each event must attach to agent and company; rollups are aggregation, - `details` jsonb null - `created_at` timestamptz not null default now() -## 7.12 `company_secrets` + `company_secret_versions` +## 7.12 `project_memberships` + `agent_memberships` + +Per-user project/agent membership is personal visibility state for board users. It only controls whether a resource appears in the current user's sidebar; it must not grant or revoke access to all-pages, detail pages, selectors, assignment flows, search, or existing permissions. + +`project_memberships`: + +- `id` uuid pk +- `company_id` uuid fk `companies.id` not null +- `project_id` uuid fk `projects.id` not null +- `user_id` text not null +- `state` enum-like text: `joined | left` +- `created_at` timestamptz not null default now() +- `updated_at` timestamptz not null default now() +- unique `(company_id, user_id, project_id)` + +`agent_memberships` mirrors the same shape with `agent_id` instead of `project_id` and unique `(company_id, user_id, agent_id)`. + +Invariants: + +- Missing membership rows mean `joined` for backward compatibility. +- Mutations are board-user-only `/me` operations; agent API keys are rejected. +- Viewer-role board users may update only their own membership rows through the narrow self-service helper. +- Target project/agent ownership is checked against the path company before mutation. +- Successful state changes write `resource_membership.joined` or `resource_membership.left` activity entries. + +## 7.13 `company_secrets` + `company_secret_versions` - Secret values are not stored inline in `agents.adapter_config.env`. - Agent env entries should use secret refs for sensitive values. @@ -323,7 +350,7 @@ Operational policy: - Activity and approval payloads must not persist raw sensitive values. - Config revisions may include redacted placeholders; such revisions are non-restorable for redacted fields. -## 7.13 Required Indexes +## 7.14 Required Indexes - `agents(company_id, status)` - `agents(company_id, reports_to)` @@ -341,8 +368,12 @@ Operational policy: - `issue_attachments(company_id, issue_id)` - `company_secrets(company_id, name)` unique - `company_secret_versions(secret_id, version)` unique +- `project_memberships(company_id, user_id)` +- `project_memberships(company_id, user_id, project_id)` unique +- `agent_memberships(company_id, user_id)` +- `agent_memberships(company_id, user_id, agent_id)` unique -## 7.14 `assets` + `issue_attachments` +## 7.15 `assets` + `issue_attachments` - `assets` stores provider-backed object metadata (not inline bytes): - `id` uuid pk @@ -376,6 +407,10 @@ Operational policy: - `created_by_user_id` uuid/text fk null - `updated_by_agent_id` uuid fk null - `updated_by_user_id` uuid/text fk null + - `locked_at` timestamptz null + - `locked_by_agent_id` uuid fk null + - `locked_by_user_id` uuid/text fk null + - Locked documents are immutable until unlocked. Board operators can lock/unlock; agent writes to a locked key create a new issue document with a derived key instead of overwriting the locked document. - `document_revisions` stores append-only history: - `id` uuid pk - `company_id` uuid fk not null @@ -396,7 +431,7 @@ The current implementation includes additional V1-control-plane tables beyond th - Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes. - Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables. -- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`. +- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. - Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos. ## 8. State Machines @@ -481,6 +516,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Report cost | yes | yes | | Set company budget | yes | no | | Set subordinate budget | yes | yes (manager subtree only) | +| Set work-object visibility (issue/project) | no | no (pro gate) | + +## 9.4 Permission Terminology and Default Visibility Rule + +Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. + +The approved term set is: + +- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines). +- **Agent config visibility**: adapter/runtime config metadata and secret-access policy. +- **Assignment/invocation permission**: who may modify or execute a task. +- **Work-object visibility**: who can read/write issues, comments, projects, and attachments. +- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs. +- **Escalation authority**: where refusal/blocked decisions route (manager, then board). + +## 9.5 Core V1 Rule: what “private” means + +- A **private marker** on an agent profile (where represented) does **not** make company-visible work private. +- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default. +- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls. + +## 9.6 V1 vs Pro/Enterprise Controls (recommended target split) + +| Permission area | Free / V1 default | Pro / Enterprise | +|---|---|---| +| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) | +| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles | +| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces | +| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement | +| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes | +| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels | +| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation | +| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows | + +## 9.7 Recommended first-slice implementation order + +1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates. +2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects. +3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks). +4. Standardize read-path redaction for secrets and secret references, including logs and activity. +5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit. + +## 9.8 Scoped Task Assignment Grants + +`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks. + +`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint: + +- Project scope: `projectId`, `projectIds`, or `allow: ["project:"]`. +- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:"]`. +- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:"]`. + +When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources. ## 10. API Contract (REST) @@ -524,6 +612,8 @@ All endpoints are under `/api` and return JSON. - `GET /issues/:issueId/documents` - `GET /issues/:issueId/documents/:key` - `PUT /issues/:issueId/documents/:key` +- `POST /issues/:issueId/documents/:key/lock` +- `POST /issues/:issueId/documents/:key/unlock` - `GET /issues/:issueId/documents/:key/revisions` - `DELETE /issues/:issueId/documents/:key` - `POST /issues/:issueId/checkout` @@ -562,14 +652,28 @@ Server behavior: - `GET /projects/:projectId` - `PATCH /projects/:projectId` -## 10.6 Approvals +## 10.6 Current-user Resource Memberships + +- `GET /companies/:companyId/resource-memberships/me` +- `PUT /companies/:companyId/resource-memberships/me/projects/:projectId` +- `PUT /companies/:companyId/resource-memberships/me/agents/:agentId` + +Request payload: + +```json +{ "state": "joined" } +``` + +Allowed states are `joined` and `left`. Endpoints require a concrete board user and active company membership, reject agent API keys, and only mutate the caller's own sidebar visibility state. Joining/leaving is idempotent; missing rows read as `joined`. + +## 10.7 Approvals - `GET /companies/:companyId/approvals?status=pending` - `POST /companies/:companyId/approvals` - `POST /approvals/:approvalId/approve` - `POST /approvals/:approvalId/reject` -## 10.7 Cost and Budgets +## 10.8 Cost and Budgets - `POST /companies/:companyId/cost-events` - `GET /companies/:companyId/costs/summary` @@ -578,7 +682,7 @@ Server behavior: - `PATCH /companies/:companyId/budgets` - `PATCH /agents/:agentId/budgets` -## 10.8 Activity and Dashboard +## 10.9 Activity and Dashboard - `GET /companies/:companyId/activity` - `GET /companies/:companyId/dashboard` @@ -590,7 +694,7 @@ Dashboard payload must include: - month-to-date spend and budget utilization - pending approvals count -## 10.9 Error Semantics +## 10.10 Error Semantics - `400` validation error - `401` unauthenticated @@ -600,7 +704,7 @@ Dashboard payload must include: - `422` semantic rule violation - `500` server error -## 10.10 Current Implementation API Addenda +## 10.11 Current Implementation API Addenda The current app also exposes V1-supporting surfaces for: @@ -671,7 +775,13 @@ Behavior: - `thin`: send IDs and pointers only; agent fetches context via API - `fat`: include current assignments, goal summary, budget snapshot, and recent comments -## 11.5 Scheduler Rules +## 11.5 Recovery Model Profiles + +The optional `modelProfiles.cheap` lane is not a retry worker lane. Paperclip may request the cheap profile only for status-only recovery coordination, and those wakes must include guard context that prevents deliverable work and document/plan updates (`allowDeliverableWork: false`, `allowDocumentUpdates: false`, `resumeRequiresNormalModel: true`). + +Failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, and downstream source-work child/requeue/resume contexts must use the normal/original model lane. If cheap recovery repairs liveness while actual work remains, the next live continuation path must be a separate normal-model worker run with cheap hints scrubbed. + +## 11.6 Scheduler Rules Per-agent schedule fields in `adapter_config`: diff --git a/doc/SPEC.md b/doc/SPEC.md index 6a7039ca..95b632d4 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down. **Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control. +Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place. + Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what. ### Cross-Team Work diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 735c88a6..38752625 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -184,7 +184,7 @@ A valid recovery action must name: - the wake, monitor, timeout, retry, or escalation policy that will move the action forward - the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled -A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue. +A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue. Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include: @@ -196,6 +196,14 @@ Use an issue-backed recovery action only when the recovery is genuinely independ A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome. +#### Recovery action freshness + +Source-scoped recovery actions are snapshots of the source issue's liveness state at the time the action was opened. They must be revalidated after newer durable source activity, including source issue status changes, assignee changes, blocker changes, execution policy or monitor changes, document or work-product updates that define a valid waiting path, and structured resume or disposition updates. + +When newer source activity restores a valid live or waiting path, the recovery action is stale and should be folded through the explicit recovery lifecycle instead of being hidden or deleted. Folding means resolving or cancelling the recovery action with a resolution outcome and note that preserve the audit trail. + +Plain comments alone do not make a recovery action stale. A comment can provide evidence, but the recovery action should remain visible when the source issue is still stalled and the comment does not create a valid action-path primitive such as a wake, monitor, interaction, approval, blocker, human owner, execution participant, terminal disposition, or delegated follow-up. + ### Agent-assigned `todo` This is dispatch state: ready to start, not yet actively claimed. @@ -322,18 +330,25 @@ Recovery rule: This is an active-work continuity recovery. +### 8.3 Recovery model-profile lane + +Cheap model profiles are only for status-only operational recovery overhead. Paperclip may request `modelProfile: "cheap"` for bounded recovery-owner work that updates task liveness, clears bad status, records a disposition, or asks for human/manager intervention. Those wakes must carry guard context such as `allowDeliverableWork: false`, `allowDocumentUpdates: false`, and `resumeRequiresNormalModel: true`. + +Automatic retries that can continue source work must use the original/normal model lane. This includes failed source-work retries, process-loss retries, transient/scheduled retries, max-turn continuations, source-assignee continuations, assigned-todo dispatch recovery, and any run that can update repo files, issue documents, plans, work products, or attachments. When a cheap status-only recovery determines that actual work remains, it must hand back to a normal-model worker run before source work or persistent deliverable updates resume. Cheap recovery hints must be scrubbed from copied retry, resume, child, and downstream source-work contexts. + ## 9. Startup and Periodic Reconciliation Startup recovery and periodic recovery are different from normal wakeup delivery. -On startup and on the periodic recovery loop, Paperclip now does four things in sequence: +On startup and on the periodic recovery loop, Paperclip now does five things in sequence: 1. reap orphaned `running` runs 2. resume persisted `queued` runs 3. reconcile stranded assigned work -4. scan silent active runs and create or update explicit watchdog recovery actions +4. scan silent active runs, revalidate their source issues, and either fold source-resolved watchdogs or create/update explicit watchdog recovery actions +5. reconcile productivity reviews -The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. +The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition. ## 10. Silent Active-Run Watchdog @@ -360,6 +375,33 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot. +### Source-aware watchdog folding + +Active-run watchdog work is source-aware. Before the watchdog creates, refreshes, escalates, or blocks on reviewer work, it must re-read the linked source issue and decide whether the watchdog signal is still about productive source work or only about stale run/process bookkeeping. + +Fold watchdog work when all of these are true: + +- the run is linked to a source issue in the same company +- the source issue is terminal (`done` or `cancelled`) +- durable source activity from the same run proves the source issue reached that terminal disposition after the stale-run or output-silence evidence point +- there is no independent evidence that the still-running or detached process is doing harmful work, still owns external cleanup that needs an operator decision, or needs a separate security/ownership review + +Folding means resolving or cancelling the watchdog recovery action or issue-backed evaluation through the explicit recovery lifecycle. It must preserve the run id, source issue, detected silence or detached-process evidence, terminal source activity, decision reason, and best-effort process cleanup result. It must be idempotent for the `(companyId, runId, sourceIssueId)` signal and must not recursively recover the watchdog evaluation issue itself. + +Do not fold watchdog work only because the run is quiet. The watchdog must still create or continue reviewer work when: + +- the source issue is still `todo` or `in_progress`, because productive work may still be happening or stuck +- the source issue remains `in_progress` after a successful run with no valid disposition, because the successful-run handoff path owns that bounded correction +- the run terminated or disappeared while the source issue remains `in_progress` without a live path, because stranded assigned recovery owns that continuity repair +- the source issue is terminal but there is no durable same-run terminal activity after the stale evidence point +- there is independent evidence that the process may still be mutating external state, leaking resources, crossing company or ownership boundaries, or otherwise needs operator review + +In the normal non-terminal case, critical silence can still create issue-backed evaluation work and block the source issue when blocking is necessary for correctness. In the source-resolved case, a completed source issue should not acquire a new manager review or blocker merely because an old run handle stayed active; only real unresolved work should block work. + +This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other. + +Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains. + ## 11. Auto-Recover vs Explicit Recovery vs Human Escalation Paperclip uses three different recovery outcomes, depending on how much it can safely infer. diff --git a/doc/plans/2026-05-05-scaled-kanban-board-design.md b/doc/plans/2026-05-05-scaled-kanban-board-design.md new file mode 100644 index 00000000..068b5995 --- /dev/null +++ b/doc/plans/2026-05-05-scaled-kanban-board-design.md @@ -0,0 +1,90 @@ +# Scaled Kanban Board Design + +Date: 2026-05-05 +Branch: `feat/scaled-kanban-board` + +## Context + +The Issues page currently supports list and board modes. List mode already has grouping, sorting, filtering, nested parent/child rows, deferred row rendering, and incremental render limits. Board mode uses classic status columns with draggable cards. It fetches per-status board data, but the current UI still presents each lane as an unbounded stack of cards, which becomes tall and heavy when a company has hundreds of issues. + +The goal is to keep the Kanban mental model while making high-volume boards usable. This is a UI-first change. It should not introduce schema changes or new API contracts in the first pass. + +## Problem + +When Paperclip has many issues, board columns get too tall and slow. The operator loses the ability to scan the board quickly, and rendering or dragging through long columns becomes unpleasant. The first version should solve this by reducing the number of visible cards per column and by collapsing low-signal columns, not by replacing Kanban with a different inventory surface. + +## Design + +Board mode remains status-column based. Each column shows its total issue count, a bounded set of visible cards, and a local affordance to reveal more cards in that column. The board should keep active workflow lanes expanded by default and collapse cold or noisy lanes once issue volume is high. + +Default high-volume behavior activates when the filtered board has more than 100 issues: + +- Compact cards are used by default. +- `backlog`, `done`, and `cancelled` auto-collapse to narrow rails. +- `todo`, `in_progress`, `in_review`, and `blocked` remain expanded by default. +- Each expanded column renders an initial 10 cards by default. +- The user can choose a page size of 10, 25, or 50 cards per column. +- The user can reveal one additional page at a time in each column without changing other columns. +- Drag and drop continues to work for visible cards. + +The toolbar should expose compact controls for: + +- toggling compact cards +- hiding or showing cold lanes +- choosing cards per column +- resetting board density to defaults + +These preferences should persist through the existing issue view-state/localStorage mechanism and remain scoped by company. + +## Component Shape + +`IssuesList` remains the owner of issue board view state. It should store board-density preferences alongside the existing issue view state, including compact card preference, cold-lane mode, and cards-per-column page size. + +`KanbanBoard` receives board tuning props from `IssuesList` and delegates per-lane display to `KanbanColumn`. + +`KanbanColumn` owns only local presentation mechanics for a lane: + +- whether the lane is rendered as an expanded column or collapsed rail +- how many cards are currently visible in that lane +- the local "show more" action + +`KanbanCard` gets a compact variant. The compact card should still show the issue identifier, title, live state, priority, and assignee when available, but with tighter spacing and fewer vertical affordances. + +## Data Flow + +The first implementation uses the current issue data already available to board mode. No database, shared type, or route change is required. + +Column totals are computed from the in-memory filtered board issues. If a column reaches the existing remote board query cap, the existing warning remains the truth source that more filtering may be required. + +Future server-side column pagination can be added later if the UI-only version is not enough for very large instances. + +## Error Handling + +This feature should not introduce new network errors. Existing issue loading and update errors continue to surface through the Issues page. + +For drag and drop: + +- Moving a visible card keeps the current optimistic behavior. +- Hidden cards remain hidden until revealed. +- A collapsed lane rail is a valid drop target. Dropping onto it moves the issue to that status and keeps the lane collapsed. + +## Testing + +Focused tests should cover: + +- board mode passes density preferences into `KanbanBoard` +- columns render only the initial visible card count +- "show more" reveals more cards in a single column +- high-volume cold lanes render as collapsed rails by default +- compact cards preserve identifier/title/live/priority/assignee signals +- drag/drop status updates still call `onUpdateIssue` + +Manual verification should include opening the Issues board with a large fixture or mocked issue set and confirming that columns remain usable with hundreds of issues. + +## Out of Scope + +- Server-side per-column pagination +- New issue schema fields +- Replacing Kanban with a dense table or action-only board +- Changing issue status semantics +- Broad visual redesign of the Issues page diff --git a/doc/plans/2026-05-05-scaled-kanban-board.md b/doc/plans/2026-05-05-scaled-kanban-board.md new file mode 100644 index 00000000..c36e00f7 --- /dev/null +++ b/doc/plans/2026-05-05-scaled-kanban-board.md @@ -0,0 +1,250 @@ +# Scaled Kanban Board Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Issues Kanban board usable with hundreds of issues by adding compact high-volume rendering, collapsed cold lanes, and per-column reveal controls. + +**Architecture:** Keep the change UI-only. `IssuesList` owns persisted board density preferences in existing company-scoped view state, while `KanbanBoard` owns lane rendering, card density, collapsed rails, and per-column "show more" state. + +**Tech Stack:** React 19, TypeScript, Vite, Vitest/jsdom, `@dnd-kit/core`, `@dnd-kit/sortable`, Tailwind utility classes. + +--- + +## File Structure + +- Modify `ui/src/components/IssuesList.tsx`: extend `IssueViewState`, derive high-volume board preferences, add toolbar controls, pass props into `KanbanBoard`. +- Modify `ui/src/components/KanbanBoard.tsx`: add compact cards, collapsed rail lanes, visible-card limits, and per-column reveal behavior. +- Create `ui/src/components/KanbanBoard.test.tsx`: focused tests for high-volume behavior and drag/drop update callback. +- Modify `ui/src/components/IssuesList.test.tsx`: update the mocked `KanbanBoard` expectations for new props. +- Keep `doc/plans/2026-05-05-scaled-kanban-board-design.md` as the design source of truth. + +## Task 1: Add Kanban Board Scaling Mechanics + +**Files:** +- Modify: `ui/src/components/KanbanBoard.tsx` +- Create: `ui/src/components/KanbanBoard.test.tsx` + +- [ ] **Step 1: Write focused tests** + +Create `ui/src/components/KanbanBoard.test.tsx` with tests that render 60 todo issues and assert: + +```tsx +renderBoard({ issues: createIssues(60, "todo"), compactCards: true, initialVisibleCount: 10, revealIncrement: 10 }); +expect(container.textContent).toContain("Showing 10 of 60"); +expect(container.textContent).toContain("Show 10 more"); +``` + +Also test collapsed rails: + +```tsx +renderBoard({ issues: createIssues(3, "done"), collapsedStatuses: ["done"] }); +expect(container.textContent).toContain("Done"); +expect(container.textContent).toContain("3"); +expect(container.textContent).not.toContain("Issue 1"); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx +``` + +Expected: fail because `KanbanBoard.test.tsx` is new and the props/behavior do not exist. + +- [ ] **Step 3: Implement minimal board behavior** + +In `KanbanBoard.tsx`, add exported constants: + +```ts +export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100; +export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; +export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE = 10; +export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const; +``` + +Extend props: + +```ts +compactCards?: boolean; +collapsedStatuses?: string[]; +initialVisibleCount?: number; +revealIncrement?: number; +``` + +Add per-status visible-count state keyed by status. Expanded columns render `issues.slice(0, visibleCount)` and show a button when hidden issues remain. Collapsed columns render a narrow droppable rail with status icon, label, and count, but no cards. + +Reset per-status visible-count state when `initialVisibleCount` or `revealIncrement` changes so choosing a smaller cards-per-column preset does not leave a column expanded past the newly selected page size. + +- [ ] **Step 4: Preserve drag/drop** + +Keep `DndContext`, `SortableContext`, and `handleDragEnd` status detection. Because collapsed rails use `useDroppable({ id: status })`, dropping a visible card onto a rail continues to resolve `targetStatus` through the existing status-id branch. + +- [ ] **Step 5: Run focused test** + +Run: + +```bash +pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/KanbanBoard.tsx ui/src/components/KanbanBoard.test.tsx +git commit -m "Scale kanban board columns" +``` + +## Task 2: Wire Board Density State Into IssuesList + +**Files:** +- Modify: `ui/src/components/IssuesList.tsx` +- Modify: `ui/src/components/IssuesList.test.tsx` + +- [ ] **Step 1: Write/update tests** + +In `IssuesList.test.tsx`, update the `KanbanBoard` mock to capture: + +```ts +compactCards?: boolean; +collapsedStatuses?: string[]; +initialVisibleCount?: number; +revealIncrement?: number; +``` + +Add a test that stores board mode in localStorage, renders more than 100 issues, and expects: + +```ts +expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ + compactCards: true, + collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]), + initialVisibleCount: 10, + revealIncrement: 10, +})); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx +``` + +Expected: fail because `IssuesList` does not pass the new props yet. + +- [ ] **Step 3: Add persisted board density preferences** + +Extend `IssueViewState`: + +```ts +boardCardDensity: "auto" | "compact" | "comfortable"; +boardColdLaneMode: "auto" | "collapsed" | "expanded"; +boardColumnPageSize: 10 | 25 | 50; +``` + +Default the density modes to `"auto"` and page size to `10`. Derive: + +```ts +const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD; +const boardCompactCards = viewState.boardCardDensity === "compact" + || (viewState.boardCardDensity === "auto" && boardHighVolume); +const boardCollapsedStatuses = viewState.boardColdLaneMode === "collapsed" + || (viewState.boardColdLaneMode === "auto" && boardHighVolume) + ? [...KANBAN_COLD_STATUSES] + : []; +``` + +- [ ] **Step 4: Add toolbar controls** + +When `viewState.viewMode === "board"`, add small outline/icon buttons near the existing view controls: + +```tsx + + + + +``` + +Use lucide icons already available or import `ChevronsDownUp`, `PanelTopClose`, and `RotateCcw`. + +- [ ] **Step 5: Pass board props** + +Update the `KanbanBoard` call: + +```tsx + +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/components/IssuesList.tsx ui/src/components/IssuesList.test.tsx +git commit -m "Wire issue board density controls" +``` + +## Task 3: Verification And PR Prep + +**Files:** +- Verify existing changes only. + +- [ ] **Step 1: Run targeted UI tests** + +```bash +pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx +``` + +Expected: pass. + +- [ ] **Step 2: Run broader cheap test path** + +```bash +pnpm test +``` + +Expected: pass. + +- [ ] **Step 3: Check worktree** + +```bash +git status --short +``` + +Expected: only intentional changes before committing, or clean after final commit. + +- [ ] **Step 4: Prepare PR** + +Read `.github/PULL_REQUEST_TEMPLATE.md` and use it for the PR body. Include: + +- design spec path +- scaled Kanban behavior summary +- test commands and results +- Model Used section with the current Codex model details available in this session + +## Self-Review + +- Spec coverage: The plan covers compact high-volume board cards, collapsed cold lanes, cards-per-column presets, per-column reveal controls, persisted board preferences, current API reuse, and focused tests. +- Placeholder scan: No unresolved markers or unspecified implementation placeholders remain. +- Type consistency: The plan consistently uses `boardCardDensity`, `boardColdLaneMode`, `boardColumnPageSize`, `compactCards`, `collapsedStatuses`, `initialVisibleCount`, and `revealIncrement`. diff --git a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md index 18b033e3..3bf7b009 100644 --- a/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md +++ b/doc/plugins/LOCAL_PLUGIN_DEVELOPMENT.md @@ -2,7 +2,12 @@ This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild. -For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md). +For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines/skills, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md). + +If your plugin has background-like recurring work, model it as managed resources: +declare managed routines plus managed agents/projects/skills, then reconcile those +resources in worker actions. This gives operators visible work items, budgets, +pause controls, and consistent audits instead of hidden daemon behavior. ## Prerequisites @@ -126,7 +131,8 @@ When you are done iterating locally, publish the package and reinstall the npm-p - **Restart cleanly:** `paperclipai plugin disable ` pauses the plugin without removing it. `paperclipai plugin enable ` brings it back. `paperclipai plugin uninstall ` removes the install record; add `--force` to also purge plugin state and settings. - **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install ` line. -- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented. +- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines/skills, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented. +- **Routine-first automation:** If your plugin should produce periodic issue work, prefer managed routines and `ctx.routines.managed` reconciliation over custom process loops or unobserved cron code. ## Troubleshooting diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index eb20163b..dca3ec62 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -13,6 +13,8 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i - Worker-side host APIs are capability-gated. - Plugin UI is not sandboxed by manifest capabilities. - Plugin database migrations are restricted to a host-derived plugin namespace. +- Plugin-managed surfaces are first-class records (agents, projects, routines, and + skills) rather than private plugin-only state. - Plugin-owned JSON API routes must be declared in the manifest and are mounted only under `/api/plugins/:pluginId/api/*`. - The host provides a small shared React component kit through @@ -74,6 +76,7 @@ Worker: - issues, comments, namespaced `plugin:` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries - agents, plugin-managed agents, and agent sessions - plugin-managed routines +- plugin-managed skills - goals - data/actions - streams @@ -134,11 +137,16 @@ paths; they always remain under `/api/plugins/:pluginId/api/*`. Plugins that provide durable Paperclip business objects should declare them in the manifest and let the host create or relink the actual records per company. -Do this for plugin-owned agents, plugin-owned projects, and recurring automation. +Do this for plugin-owned agents, projects, routines, and skills. Do not hide long-lived work behind private plugin state when it should be visible to the board, scoped to a company, audited, budgeted, and assigned like normal Paperclip work. +Content-oriented plugins, such as LLM Wiki-style ingestion or durable knowledge +systems, should use the same pattern: managed projects for operation issues, +managed agents plus managed skills for LLM work, and managed routines for +ingest, lint, refresh, or maintenance runs. + Use these surfaces: - Managed agents: declare top-level `agents[]` and require @@ -155,10 +163,14 @@ Use these surfaces: jobs that should create visible Paperclip issues. Prefer managed routines over plugin `jobs[]` for recurring business work; plugin jobs are for plugin runtime maintenance that does not need a board-visible task trail. +- Managed skills: declare top-level `skills[]` and require `skills.managed`. + Use this for reusable plugin capabilities that should be surfaced to operators and + synced into Paperclip managed agents. Managed resources are resolved by stable plugin keys, not hardcoded database ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`, -`ctx.projects.managed.reconcile()`, and `ctx.routines.managed.reconcile()` for +`ctx.projects.managed.reconcile()`, `ctx.routines.managed.reconcile()`, and +`ctx.skills.managed.reconcile()` for the current `companyId`. `reconcile()` creates the missing resource, relinks a recoverable binding, or returns the existing resource. `reset()` reapplies the manifest defaults when the operator wants to restore the plugin's suggested @@ -185,6 +197,7 @@ const manifest: PaperclipPluginManifestV1 = { "agents.managed", "projects.managed", "routines.managed", + "skills.managed", "instance.settings.register", ], entrypoints: { @@ -231,6 +244,13 @@ const manifest: PaperclipPluginManifestV1 = { ], }, ], + skills: [ + { + skillKey: "weekly-brief-skills", + displayName: "Weekly Briefer", + description: "Reusable skill for the managed research workflow.", + }, + ], ui: { slots: [ { @@ -261,8 +281,9 @@ export default definePlugin({ const project = await ctx.projects.managed.reconcile("research", companyId); const agent = await ctx.agents.managed.reconcile("researcher", companyId); const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId); + const skill = await ctx.skills.managed.reconcile("weekly-brief-skills", companyId); - return { project, agent, routine }; + return { project, agent, routine, skill }; }); }, }); @@ -270,14 +291,18 @@ export default definePlugin({ Authoring rules: -- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or - `routineKey` creates a new managed resource from the host's point of view. +- Keep keys stable once published. Renaming `agentKey`, `projectKey`, + `routineKey`, or `skillKey` creates a new managed resource from the host's + point of view. - Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or `ctx.agents.sessions` only after you have a real agent id, either selected by the operator or resolved from `ctx.agents.managed`. - Use managed routines for recurring or externally triggered work that should produce tasks. Schedule, webhook, and API triggers are visible routine triggers, and each run has the normal Paperclip issue/audit trail. +- Use managed skills for reusable operator-visible capabilities that are shared + by managed agents. Reconcile skill declarations by `skillKey` and keep the + declared skill markdown and files in sync with agent behavior. - Use managed projects to keep plugin-generated work organized and to give project-scoped plugin UI a stable home. For filesystem access inside a project, still resolve project workspaces through `ctx.projects`. @@ -300,6 +325,7 @@ Mount surfaces currently wired in the host include: - `settingsPage` - `dashboardWidget` - `sidebar` +- `routeSidebar` - `sidebarPanel` - `detailTab` - `taskDetailView` @@ -317,6 +343,10 @@ Paperclip-native control. The host owns the implementation, so plugins inherit the board's current styling, ordering, recent selections, and dark-mode behavior without importing `ui/src` internals. +Prefer shared components for common Paperclip UX patterns to reduce drift and +deprecation risk, especially for task/assignment flows and routine or sidebar-like +plugin screens. + Currently exposed components include: - `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown. diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 31b9c9d0..d1bd1034 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -319,7 +319,10 @@ export interface PaperclipPluginManifestV1 { version: string; displayName: string; description: string; + author: string; categories: Array<"connector" | "workspace" | "automation" | "ui">; + minimumHostVersion?: string; + /** @deprecated Use `minimumHostVersion` instead. Retained for backwards compatibility. */ minimumPaperclipVersion?: string; capabilities: string[]; entrypoints: { @@ -335,15 +338,42 @@ export interface PaperclipPluginManifestV1 { description: string; parametersSchema: JsonSchema; }>; + database?: PluginDatabaseDeclaration; + apiRoutes?: PluginApiRouteDeclaration[]; + environmentDrivers?: PluginEnvironmentDriverDeclaration[]; + agents?: PluginManagedAgentDeclaration[]; + projects?: PluginManagedProjectDeclaration[]; + routines?: PluginManagedRoutineDeclaration[]; + skills?: PluginManagedSkillDeclaration[]; + localFolders?: PluginLocalFolderDeclaration[]; + /** Legacy top-level launcher declarations. Prefer `ui.launchers` for new manifests. */ + launchers?: PluginLauncherDeclaration[]; ui?: { + launchers?: PluginLauncherDeclaration[]; slots: Array<{ - type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage"; + type: "page" + | "detailTab" + | "taskDetailView" + | "dashboardWidget" + | "sidebar" + | "routeSidebar" + | "sidebarPanel" + | "projectSidebarItem" + | "globalToolbarButton" + | "toolbarButton" + | "contextMenuItem" + | "commentAnnotation" + | "commentContextMenuItem" + | "settingsPage" + | "companySettingsPage"; id: string; displayName: string; /** Which export name in the UI bundle provides this component */ exportName: string; /** For detailTab: which entity types this tab appears on */ entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; + /** For page and companySettingsPage: single route segment */ + routePath?: string; }>; }; } @@ -354,10 +384,17 @@ Rules: - `id` must be globally unique - `id` should normally equal the npm package name - `apiVersion` must match the host-supported plugin API version +- `minimumHostVersion` is preferred, with `minimumPaperclipVersion` retained for + backwards compatibility - `capabilities` must be static and install-time visible - config schema must be JSON Schema compatible - `entrypoints.ui` points to the directory containing the built UI bundle - `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle +- declare managed declarations with the matching `*.managed` capability: + - `agents` → `agents.managed` + - `projects` → `projects.managed` + - `routines` → `routines.managed` + - `skills` → `skills.managed` ## 11. Agent Tools @@ -631,6 +668,22 @@ Plugins that need filesystem, git, terminal, or process operations handle those Trusted orchestration plugins can create and update Paperclip issues through `ctx.issues` instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata. +Plugins that perform durable work should declare managed Paperclip resources rather than using private plugin state: + +- `agents` + `ctx.agents.managed.*` for named, invokable operators (`agents.managed` required) +- `projects` + `ctx.projects.managed.*` for stable, scoped issue/workspace ownership (`projects.managed` required) +- `routines` + `ctx.routines.managed.*` for schedule/webhook/manual execution with issue trails (`routines.managed` required) +- `skills` + `ctx.skills.managed.*` for reusable agent capabilities (`skills.managed` required) + +The LLM Wiki plugin is the current reference for this pattern: it declares managed +agents, projects, routines, and skills in manifest, reconciles them per company, +and uses managed routines for periodic wiki maintenance and ingest operations. +Content-oriented plugins should follow the same model instead of running +unmanaged background loops: make the LLM-facing worker an operator-visible +managed agent, attach reusable prompt/tool guidance as managed skills, keep +operation issues in a managed project, and drive recurring work through managed +routines. + Origin rules: - Built-in core issues keep built-in origins such as `manual` and `routine_execution`. @@ -746,20 +799,38 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr - `activity.read` - `costs.read` - `issues.orchestration.read` +- `database.namespace.read` ### Data Write - `issues.create` - `issues.update` - `issue.comments.create` +- `issue.interactions.create` - `issue.documents.write` - `issue.relations.write` - `issues.checkout` - `issues.wakeup` -- `assets.write` -- `assets.read` - `activity.log.write` - `metrics.write` +- `telemetry.track` +- `assets.read` +- `assets.write` +- `database.namespace.migrate` +- `database.namespace.write` +- `goals.create` +- `goals.update` +- `projects.managed` +- `routines.managed` +- `skills.managed` +- `agents.managed` +- `agents.pause` +- `agents.resume` +- `agents.invoke` +- `agent.sessions.create` +- `agent.sessions.list` +- `agent.sessions.send` +- `agent.sessions.close` ### Plugin State @@ -772,8 +843,10 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr - `events.emit` - `jobs.schedule` - `webhooks.receive` +- `local.folders` - `http.outbound` - `secrets.read-ref` +- `environment.drivers.register` ### Agent Tools @@ -786,6 +859,7 @@ The host enforces capabilities in the SDK layer and refuses calls outside the gr - `ui.page.register` - `ui.detailTab.register` - `ui.dashboardWidget.register` +- `ui.commentAnnotation.register` - `ui.action.register` ## 15.2 Forbidden Capabilities @@ -894,6 +968,7 @@ Job rules: 3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later. 4. Every job run is recorded in Postgres. 5. Failed jobs are retryable. +6. For recurring business workflows that should create visible Paperclip work, prefer managed routines and managed resources over jobs. Jobs remain useful for private plugin-runtime maintenance tasks. ## 18. Webhooks @@ -1134,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards. +For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations. + ## 20. Local Tooling Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. @@ -1383,6 +1460,14 @@ Each plugin may expose a company-context main page: This page is where board users do most day-to-day work. +## 24.4 Company Settings Plugin Page + +Each ready plugin may expose a company settings page: + +- `/:companyPrefix/company/settings/:routePath` + +The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages. + ## 25. Uninstall And Data Lifecycle When a plugin is uninstalled, the host must handle plugin-owned data explicitly. diff --git a/doc/screenshots/pr-6381/aws-discovery-candidates.png b/doc/screenshots/pr-6381/aws-discovery-candidates.png new file mode 100644 index 00000000..9006d175 Binary files /dev/null and b/doc/screenshots/pr-6381/aws-discovery-candidates.png differ diff --git a/doc/screenshots/pr-6381/provider-vaults-tab.png b/doc/screenshots/pr-6381/provider-vaults-tab.png new file mode 100644 index 00000000..6d304c20 Binary files /dev/null and b/doc/screenshots/pr-6381/provider-vaults-tab.png differ diff --git a/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png b/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png new file mode 100644 index 00000000..40a4c81a Binary files /dev/null and b/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png differ diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md index 504744b0..ac71d79d 100644 --- a/doc/spec/invite-flow.md +++ b/doc/spec/invite-flow.md @@ -6,7 +6,7 @@ Date: 2026-04-13 This document maps the current invite creation and acceptance states implemented in: - `ui/src/pages/CompanyInvites.tsx` -- `ui/src/pages/CompanySettings.tsx` +- `ui/src/components/NewAgentDialog.tsx` - `ui/src/pages/InviteLanding.tsx` - `server/src/routes/access.ts` - `server/src/lib/join-request-dedupe.ts` @@ -23,16 +23,16 @@ This document maps the current invite creation and acceptance states implemented ```mermaid flowchart TD - Board[Board user on invite screen] + Board[Board user on invite or add-agent screen] HumanInvite[Create human company invite] - OpenClawInvite[Generate OpenClaw invite prompt] + AgentInvite[Generate agent onboarding prompt] Active[Invite state: active] Revoked[Invite state: revoked] Expired[Invite state: expired] Accepted[Invite state: accepted] BootstrapDone[Bootstrap accepted
no join request] HumanReuse{Matching human join request
already exists for same user/email?} - HumanPending[Join request
pending_approval] + HumanPending[Legacy human join request
pending_approval] HumanApproved[Join request
approved] HumanRejected[Join request
rejected] AgentPending[Agent join request
pending_approval
+ optional claim secret] @@ -44,7 +44,7 @@ flowchart TD OpenClawReplay[Special replay path:
accepted invite can be POSTed again
for openclaw_gateway only] Board --> HumanInvite --> Active - Board --> OpenClawInvite --> Active + Board --> AgentInvite --> Active Active --> Revoked: revoke Active --> Expired: expiresAt passes @@ -52,12 +52,10 @@ flowchart TD BootstrapDone --> Accepted Active --> HumanReuse: human accept - HumanReuse --> HumanPending: reuse existing pending request - HumanReuse --> HumanApproved: reuse existing approved request - HumanReuse --> HumanPending: no reusable request
create new request - HumanPending --> HumanApproved: board approves + HumanReuse --> HumanApproved: reuse existing pending/approved request
ensure active membership + HumanReuse --> HumanApproved: no reusable request
create and approve request + HumanPending --> HumanApproved: same invitee replays accepted invite
or board approves legacy request HumanPending --> HumanRejected: board rejects - HumanPending --> Accepted HumanApproved --> Accepted Active --> AgentPending: agent accept @@ -102,10 +100,10 @@ stateDiagram-v2 LatestInviteVisible --> Ready: navigate away or refresh } - CompanySelection --> OpenClawPromptReady: Company settings prompt generator - OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt - OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated - OpenClawPromptPending --> OpenClawPromptReady: generation failed + CompanySelection --> AgentPromptReady: Add-agent modal prompt generator + AgentPromptReady --> AgentPromptPending: Generate agent onboarding prompt + AgentPromptPending --> AgentSnippetVisible: prompt generated + AgentPromptPending --> AgentPromptReady: generation failed ``` ## Invite Landing Screen States @@ -150,7 +148,8 @@ stateDiagram-v2 state AcceptedInviteSummary { [*] --> SummaryBranch - SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> AcceptPending: human joinRequestStatus=pending_approval/approved
and membership missing + SummaryBranch --> PendingApprovalReload: agent joinRequestStatus=pending_approval SummaryBranch --> OpeningCompany: joinRequestStatus=approved
and human invite user is now a member SummaryBranch --> RejectedReload: joinRequestStatus=rejected SummaryBranch --> ConsumedReload: approved agent invite or other consumed state @@ -177,6 +176,7 @@ sequenceDiagram participant Landing as Invite landing UI participant Auth as Auth session participant Join as join_requests table + participant Membership as company_memberships + grants Board->>Settings: Choose role and click Create invite Settings->>API: POST /api/companies/:companyId/invites @@ -197,15 +197,19 @@ sequenceDiagram API->>Join: Look for reusable human join request alt Reusable pending or approved request exists API->>Invites: Mark invite accepted - API-->>Landing: Existing join request status + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed + API-->>Landing: approved join request else No reusable request exists API->>Invites: Mark invite accepted API->>Join: Insert pending_approval join request - API-->>Landing: New pending_approval join request + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved + API-->>Landing: approved join request end ``` -### Human Approval And Reload Path +### Legacy Human Reload And Repair Path ```mermaid sequenceDiagram @@ -214,8 +218,6 @@ sequenceDiagram participant Landing as Invite landing UI participant API as Access routes participant Join as join_requests table - actor Approver as Company admin - participant Queue as Access queue UI participant Membership as company_memberships + grants Invitee->>Landing: Reload consumed invite URL @@ -223,20 +225,15 @@ sequenceDiagram API->>Join: Load join request by inviteId API-->>Landing: joinRequestStatus + joinRequestType - alt joinRequestStatus = pending_approval - Landing-->>Invitee: Show waiting-for-approval panel - Approver->>Queue: Review request in Company Settings -> Access - Queue->>API: POST /companies/:companyId/join-requests/:requestId/approve - API->>Membership: Ensure membership and grants - API->>Join: Mark join request approved - Invitee->>Landing: Refresh after approval - Landing->>API: GET /api/invites/:token - API->>Join: Reload approved join request + alt human joinRequestStatus = pending_approval or approved but membership missing + Landing->>API: POST /api/invites/:token/accept (requestType=human) + API->>Membership: Ensure active membership and role grants + API->>Join: Mark join request approved if needed API-->>Landing: approved status Landing-->>Invitee: Opening company and redirect else joinRequestStatus = rejected Landing-->>Invitee: Show rejected error panel - else joinRequestStatus = approved but membership missing + else agent invite or unavailable consumed state Landing-->>Invitee: Fall through to consumed/unavailable state end ``` @@ -247,21 +244,21 @@ sequenceDiagram sequenceDiagram autonumber actor Board as Board user - participant Settings as Company Settings UI + participant AddAgent as Add agent modal participant API as Access routes participant Invites as invites table - actor Gateway as OpenClaw gateway agent + actor Gateway as External agent participant Join as join_requests table actor Approver as Company admin participant Agents as agents table participant Keys as agent_api_keys table - Board->>Settings: Generate OpenClaw invite prompt - Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt + Board->>AddAgent: Generate agent onboarding prompt + AddAgent->>API: POST /api/companies/:companyId/invites (allowedJoinTypes=agent) API->>Invites: Insert active agent invite - API-->>Settings: Prompt text + invite token + API-->>AddAgent: Prompt text + invite token - Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway) + Gateway->>API: POST /api/invites/:token/accept (agent, adapter-specific payload) API->>Invites: Mark invite accepted API->>Join: Insert pending_approval join request + claimSecretHash API-->>Gateway: requestId + claimSecret + claimApiKeyPath @@ -286,14 +283,15 @@ sequenceDiagram ## Notes - `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. -- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- Human acceptance consumes the invite, creates or reuses the matching human join request, immediately marks it `approved`, and ensures an active company membership with the invite's selected role/grants. - The landing page has two layers of post-accept UI: - immediate mutation-result UI from `POST /api/invites/:token/accept` - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed - Reload behavior for accepted company invites is now status-sensitive: - - `pending_approval` re-renders the waiting-for-approval panel + - human `pending_approval` or `approved` states replay acceptance for the same signed-in user/email so legacy consumed invites can repair missing membership + - agent `pending_approval` re-renders the waiting-for-approval panel - `rejected` renders the "This join request was not approved." error panel - - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state + - `approved` becomes a success path for human invites after membership is visible to the current session - `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. -- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- Accepted-invite replay is supported for matching human invitees to repair/complete membership, and for `agent` requests with `adapterType=openclaw_gateway` when the existing join request is still `pending_approval` or already `approved`. - `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png new file mode 100644 index 00000000..eaaff14c Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png new file mode 100644 index 00000000..8d6794d9 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png differ diff --git a/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png new file mode 100644 index 00000000..ebb28fc0 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/sidebar-desktop.png b/docs/pr-screenshots/pr-6384/sidebar-desktop.png new file mode 100644 index 00000000..9adca66b Binary files /dev/null and b/docs/pr-screenshots/pr-6384/sidebar-desktop.png differ diff --git a/package.json b/package.json index 152fe471..60b80d5a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", - "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general", - "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized", + "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general", + "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 9a5f3716..86024755 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -1,20 +1,57 @@ export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***"; -const COMMAND_CLI_SECRET_OPTION_RE = - /(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi; -const COMMAND_ENV_SECRET_ASSIGNMENT_RE = - /(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi; +const SECRET_NAME_PATTERN = + String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; + +const COMMAND_CLI_SECRET_OPTION_RE = new RegExp( + String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`, + "gi", +); +const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp( + String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`, + "gi", +); const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g; const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g; const COMMAND_JWT_RE = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g; +const COMMAND_SECRET_HINTS = [ + "api", + "key", + "token", + "auth", + "bearer", + "secret", + "pass", + "credential", + "jwt", + "private", + "cookie", + "connectionstring", + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", +] as const; + +function maybeContainsSecretText(command: string) { + const lower = command.toLowerCase(); + return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes("."); +} export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string { + if (!maybeContainsSecretText(command)) return command; return command .replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) - .replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`) + .replace( + COMMAND_ENV_SECRET_ASSIGNMENT_RE, + (_match, prefix: string, quote: string | undefined) => + quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`, + ) .replace(COMMAND_OPENAI_KEY_RE, redactedValue) .replace(COMMAND_GITHUB_TOKEN_RE, redactedValue) .replace(COMMAND_JWT_RE, redactedValue); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 62375d7d..13e9e912 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -138,6 +138,13 @@ async function createTarballFromDirectory(input: { const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]); await execTar([ "-c", + // Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended + // headers for extended attributes (e.g. com.apple.provenance). GNU tar on + // Linux does not recognise these proprietary headers and fails extraction + // with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in + // execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs + // additionally suppresses the inline PAX xattr entries. + "--no-xattrs", ...(input.followSymlinks ? ["-h"] : []), "-f", input.archivePath, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 654b1929..3224b3d8 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -53,13 +53,14 @@ describe("buildInvocationEnvForLogs", () => { const loggedEnv = buildInvocationEnvForLogs( { SAFE_VALUE: "visible" }, { - resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret", + resolvedCommand: + "env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret", }, ); expect(loggedEnv.SAFE_VALUE).toBe("visible"); expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe( - "env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***", + "env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***", ); }); }); @@ -462,6 +463,50 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("named unblock owner/action"); }); + it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => { + const title = "验证中文任务"; + const commentBody = [ + "请用中文回复。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const payload = { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-9452", + title, + status: "in_progress", + workMode: "standard", + }, + commentIds: ["comment-1"], + latestCommentId: "comment-1", + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, + comments: [ + { + id: "comment-1", + body: commentBody, + author: { type: "user", id: "board-user-1" }, + createdAt: "2026-05-15T16:30:00.000Z", + }, + ], + fallbackFetchNeeded: false, + }; + + const serialized = stringifyPaperclipWakePayload(payload); + expect(serialized).toContain(title); + expect(serialized).toContain("日本語"); + expect(serialized).toContain("हिन्दी"); + expect(JSON.parse(serialized ?? "{}")).toMatchObject({ + issue: { title }, + comments: [{ body: commentBody }], + }); + + const prompt = renderPaperclipWakePrompt(payload); + expect(prompt).toContain(`- issue: PAP-9452 ${title}`); + expect(prompt).toContain(commentBody); + }); + it("renders planning-mode directives for assignment and comment wakes", () => { const assignmentPrompt = renderPaperclipWakePrompt({ reason: "issue_assigned", diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index b009b1bc..4d1f28af 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import type { AcpRuntimeOptions } from "acpx/runtime"; import { createAcpxLocalExecutor } from "./execute.js"; const tempRoots: string[] = []; @@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => { expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1); }); + it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const runStderrDir = path.join(stateDir, "run-stderr"); + await fs.mkdir(runStderrDir, { recursive: true }); + const stderrTail = "claude-agent-acp: SDK init failed (auth missing)"; + await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8"); + + class FakeAcpRuntimeError extends Error { + readonly code = "ACP_SESSION_INIT_FAILED"; + readonly cause: Error; + readonly retryable = false; + constructor(message: string, cause: Error) { + super(message); + this.name = "AcpRuntimeError"; + this.cause = cause; + } + } + + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxLocalExecutor({ + createRuntime: () => ({ + ensureSession: async () => { + throw new FakeAcpRuntimeError( + "session/new failed: backend rejected initialize", + new Error("upstream timeout"), + ); + }, + startTurn: () => ({ + events: (async function* () {})(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("acpx_session_init_failed"); + const meta = result.errorMeta ?? {}; + expect(meta.errorName).toBe("AcpRuntimeError"); + expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(meta.causeMessage).toBe("upstream timeout"); + expect(meta.retryable).toBe(false); + expect(typeof meta.stackPreview).toBe("string"); + expect(meta.phase).toBe("ensure_session"); + + const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\"")); + expect(errorLogLine).toBeTruthy(); + const errorPayload = JSON.parse(errorLogLine!.text.trim()); + expect(errorPayload.phase).toBe("ensure_session"); + expect(errorPayload.errorName).toBe("AcpRuntimeError"); + expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(errorPayload.causeMessage).toBe("upstream timeout"); + expect(errorPayload.childStderrTail).toContain("SDK init failed"); + + const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail")); + expect(stderrLog).toBeTruthy(); + expect(stderrLog!.text).toContain(stderrTail); + }); + + it("writes wrapper that redirects child stderr to a per-run log file", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as unknown as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + + const result = await execute({ + runId: "run-stderr-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose); + // verbose is scoped to the claude agent (PAPA-388); the custom agent here + // should not opt in to ACPX runtime verbose session-event logs. + expect(verboseFlags.every((flag) => flag === false)).toBe(true); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + const wrapperFile = wrappers.find((name) => name.endsWith(".sh")); + expect(wrapperFile).toBeTruthy(); + const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8"); + expect(wrapper).toContain("stderr_dir="); + expect(wrapper).toContain("run-stderr"); + expect(wrapper).toContain("PAPERCLIP_RUN_ID"); + expect(wrapper).toContain("tee -a"); + expect(wrapper).toContain("exec node ./fake-acp.js"); + }); + it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => { let observedApiKeyDuringStream: string | undefined; const execute = createAcpxLocalExecutor({ @@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => { else process.env.PAPERCLIP_API_KEY = previousApiKey; } }); + + it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } }, + ); + + const settingsPath = path.join(cwd, ".claude", "settings.local.json"); + const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + permissions?: { + allow?: unknown; + additionalDirectories?: unknown; + defaultMode?: unknown; + }; + }; + expect(written.permissions?.defaultMode).toBe("default"); + const allow = written.permissions?.allow; + expect(Array.isArray(allow)).toBe(true); + expect(allow).toContain("Bash(curl:*)"); + expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`); + const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined; + expect(Array.isArray(additionalDirectories)).toBe(true); + expect(additionalDirectories).toContain(stateDir); + expect(additionalDirectories).toContain(path.join(root, "agent-home")); + + const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("Paperclip-managed Claude settings"), + ); + expect(note).toBeTruthy(); + }); + + it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify( + { + statusLine: { type: "command", command: "preserve-me" }, + permissions: { + allow: ["Bash(npm test:*)"], + additionalDirectories: ["/Users/example/custom"], + defaultMode: "acceptEdits", + }, + }, + null, + 2, + ), + "utf8", + ); + + await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { + statusLine?: unknown; + permissions?: { + allow?: string[]; + additionalDirectories?: string[]; + defaultMode?: string; + }; + }; + expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" }); + expect(written.permissions?.defaultMode).toBe("acceptEdits"); + expect(written.permissions?.allow).toContain("Bash(npm test:*)"); + expect(written.permissions?.allow).toContain("Bash(curl:*)"); + expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom"); + expect(written.permissions?.additionalDirectories).toContain(stateDir); + }); + + it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2), + "utf8", + ); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { permissions?: { defaultMode?: string } }; + expect(written.permissions?.defaultMode).toBe("default"); + + const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("overrode user dontAsk"), + ); + expect(overrideNote).toBeTruthy(); + }); + + it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const verboseByAgent: Record = {}; + for (const agent of ["claude", "codex", "custom"] as const) { + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + const result = await execute({ + runId: `run-${agent}`, + agent: { id: `agent-${agent}`, companyId: "company-1" }, + runtime: {}, + config: + agent === "custom" + ? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd } + : { agent, stateDir: path.join(root, `state-${agent}`), cwd }, + context: { paperclipWorkspace: { cwd } }, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + expect(result.exitCode).toBe(0); + verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose; + } + + expect(verboseByAgent.claude).toBe(true); + expect(verboseByAgent.codex).toBe(false); + expect(verboseByAgent.custom).toBe(false); + }); + + it("does not touch .claude/settings.local.json for the codex agent", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + await runExecutor( + { agent: "codex", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false); + }); }); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 4914af44..d465b02c 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -94,6 +94,8 @@ interface AcpxPreparedRuntime { remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; + childStderrLogPath: string | null; + paperclipClaudeSettings: PaperclipClaudeSettingsResult | null; } const defaultWarmHandles = new Map(); @@ -564,11 +566,105 @@ function buildSessionParams(input: { }; } +interface PaperclipClaudeSettingsResult { + filePath: string; + allow: string[]; + additionalDirectories: string[]; + defaultMode: string; + overrodeDontAsk: boolean; +} + +function uniqueSorted(values: Array): string[] { + return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort(); +} + +// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses +// `settingSources: ["user", "project", "local"]`. By writing a per-worktree +// `.claude/settings.local.json` we override the user's potentially-restrictive +// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently +// denies every non-allowlisted tool and never reaches `canUseTool`), and we +// widen the SDK's Read sandbox to include the Paperclip state dirs the agent +// needs to talk to its own control plane. +async function writePaperclipClaudeSettings(input: { + cwd: string; + stateDir: string; + agentHome: string; + companyId: string; +}): Promise { + const filePath = path.join(input.cwd, ".claude", "settings.local.json"); + const instanceRoot = defaultPaperclipInstanceDir(); + const companyRoot = path.join(instanceRoot, "companies", input.companyId); + const paperclipAdditionalDirectories = uniqueSorted([ + input.stateDir, + input.agentHome, + companyRoot, + ]); + const paperclipAllow = uniqueSorted([ + "Bash(curl:*)", + "Bash(env:*)", + "Bash(env)", + `Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`, + `Bash(${input.cwd}/scripts/paperclip:*)`, + ]); + + let existing: Record = {}; + const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null); + if (existingRaw) { + try { + const parsed = JSON.parse(existingRaw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record; + } catch { + // Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one. + } + } + const existingPerms = + existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions) + ? (existing.permissions as Record) + : {}; + const existingAllow = Array.isArray(existingPerms.allow) + ? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories) + ? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]); + const mergedAdditionalDirectories = uniqueSorted([ + ...existingAdditionalDirectories, + ...paperclipAdditionalDirectories, + ]); + const existingDefaultMode = + typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : ""; + const defaultMode = + existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default"; + const overrodeDontAsk = existingDefaultMode === "dontAsk"; + + const nextPermissions: Record = { + ...existingPerms, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + }; + const next: Record = { ...existing, permissions: nextPermissions }; + await writeFileAtomically({ + target: filePath, + contents: `${JSON.stringify(next, null, 2)}\n`, + mode: 0o600, + }); + return { + filePath, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + overrodeDontAsk, + }; +} + async function writeAgentWrapper(input: { stateDir: string; acpxAgent: string; agentCommandShell: string; env: Record; + childStderrDir: string; }): Promise<{ wrapperPath: string; envFilePath: string }> { const wrappersDir = path.join(input.stateDir, "wrappers"); await fs.mkdir(wrappersDir, { recursive: true }); @@ -580,6 +676,7 @@ async function writeAgentWrapper(input: { agent: input.acpxAgent, command: input.agentCommandShell, env: envLines, + childStderrDir: input.childStderrDir, }); const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`); const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`); @@ -592,6 +689,11 @@ async function writeAgentWrapper(input: { " source \"$env_file\"", " set +a", "fi", + `stderr_dir=${shellQuote(input.childStderrDir)}`, + "if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then", + " mkdir -p \"$stderr_dir\"", + " exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)", + "fi", `exec ${input.agentCommandShell} "$@"`, "", ].join("\n"); @@ -723,10 +825,20 @@ async function buildRuntime(input: { if (typeof value === "string") env[key] = value; } if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; + // For the claude agent, set model via ANTHROPIC_MODEL at startup rather than + // via session/set_config_option — the ACP server's set_config_option handler + // validates the value against its internal available-models list and rejects + // bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model + // entry in some versions. ANTHROPIC_MODEL is read during initialization, so + // it reliably sets the model before any turns are run. + if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) { + env.ANTHROPIC_MODEL = requestedModel; + } let skillPromptInstructions = ""; let skillsIdentity: Record = { mode: "unsupported" }; const skillCommandNotes: string[] = []; + let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null; if (acpxAgent === "claude") { const preparedSkills = await prepareClaudeSkillRuntime({ stateDir, @@ -736,6 +848,17 @@ async function buildRuntime(input: { skillPromptInstructions = preparedSkills.promptInstructions; skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); + paperclipClaudeSettings = await writePaperclipClaudeSettings({ + cwd, + stateDir, + agentHome, + companyId: agent.companyId, + }); + skillCommandNotes.push( + `Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${ + paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : "" + }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`, + ); } else if (acpxAgent === "codex") { const preparedSkills = await prepareCodexSkillRuntime({ companyId: agent.companyId, @@ -757,12 +880,15 @@ async function buildRuntime(input: { const builtInCommand = resolveBuiltInAgentCommand(acpxAgent); const agentCommand = configuredCommand || builtInCommand || null; const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : ""); + const childStderrDir = path.join(stateDir, "run-stderr"); + const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; const wrapper = agentCommand ? await writeAgentWrapper({ stateDir, acpxAgent, agentCommandShell, env, + childStderrDir, }) : null; const wrapperPath = wrapper?.wrapperPath ?? null; @@ -781,6 +907,13 @@ async function buildRuntime(input: { remoteExecutionIdentity, skillsIdentity, skillPromptInstructions, + paperclipClaudeSettings: paperclipClaudeSettings + ? { + allow: paperclipClaudeSettings.allow, + additionalDirectories: paperclipClaudeSettings.additionalDirectories, + defaultMode: paperclipClaudeSettings.defaultMode, + } + : null, }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; @@ -817,12 +950,19 @@ async function buildRuntime(input: { ...skillsIdentity, commandNotes: skillCommandNotes, }, + childStderrLogPath, + paperclipClaudeSettings, }; } function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> { const options: Array<{ key: string; value: string }> = []; - if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel }); + // Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at + // startup; skip set_config_option to avoid ACP-server model-name validation + // that rejects bare IDs like "claude-opus-4-7" in some runtime versions. + if (prepared.requestedModel && prepared.acpxAgent !== "claude") { + options.push({ key: "model", value: prepared.requestedModel }); + } if (prepared.requestedThinkingEffort) { options.push({ key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort", @@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { return result.error.message; } -function classifyError(err: unknown): Pick { - const message = err instanceof Error ? err.message : String(err); +type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn"; + +function describeErrorDiagnostics(err: unknown): { + errorName: string; + acpCode: string | null; + causeMessage: string | null; + retryable: boolean | null; + stackPreview: string | null; +} { + const errorName = + err instanceof Error ? err.name || err.constructor.name : typeof err; const maybeCode = err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string" ? (err as { code: string }).code : null; - const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const acpCode = + isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const cause = + err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined + ? (err as { cause?: unknown }).cause + : undefined; + const causeMessage = + cause instanceof Error + ? cause.message + : typeof cause === "string" + ? cause + : null; + const retryable = + err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean" + ? (err as { retryable: boolean }).retryable + : null; + const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : ""; + const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null; + return { errorName, acpCode, causeMessage, retryable, stackPreview }; +} + +function classifyError( + err: unknown, + phase?: AcpxExecutionPhase, +): Pick { + const message = err instanceof Error ? err.message : String(err); + const diagnostics = describeErrorDiagnostics(err); + const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics; + const baseMeta: Record = { + errorName, + ...(acpCode ? { acpCode } : {}), + ...(causeMessage ? { causeMessage } : {}), + ...(retryable !== null ? { retryable } : {}), + ...(stackPreview ? { stackPreview } : {}), + ...(phase ? { phase } : {}), + }; const lower = message.toLowerCase(); const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential"); if (authLike) { return { errorCode: "acpx_auth_required", - errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) }, + errorMeta: { category: "auth", ...baseMeta }, + }; + } + const phaseCode = (() => { + if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed"; + if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed"; + if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing"; + if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable"; + if (phase === "ensure_session") return "acpx_session_init_failed"; + if (phase === "configure_session") return "acpx_session_config_failed"; + if (phase === "turn") return "acpx_turn_failed"; + return null; + })(); + if (phaseCode) { + return { + errorCode: phaseCode, + errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta }, }; } if (acpCode) { return { errorCode: "acpx_protocol_error", - errorMeta: { category: "protocol", acpCode }, + errorMeta: { category: "protocol", ...baseMeta }, }; } return { errorCode: "acpx_runtime_error", - errorMeta: { category: "runtime" }, + errorMeta: { category: "runtime", ...baseMeta }, }; } +async function readChildStderrTail(input: { + logPath: string | null; + maxBytes?: number; +}): Promise { + if (!input.logPath) return null; + const maxBytes = input.maxBytes ?? 4096; + let handle: fs.FileHandle | null = null; + try { + const stat = await fs.stat(input.logPath); + if (stat.size === 0) return null; + handle = await fs.open(input.logPath, "r"); + const readBytes = Math.min(stat.size, maxBytes); + const buffer = Buffer.alloc(readBytes); + await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes)); + const tail = buffer.toString("utf8").trim(); + return tail.length > 0 ? tail : null; + } catch { + return null; + } finally { + if (handle) await handle.close().catch(() => {}); + } +} + +async function emitAcpxFailure(input: { + ctx: AdapterExecutionContext; + prepared: AcpxPreparedRuntime; + err: unknown; + phase: AcpxExecutionPhase; + // Replace the err-derived message in both the stderr-tail log header and the + // acpx.error payload. Used by the turn path to surface "Timed out after Ns" + // instead of the raw underlying error message. + messageOverride?: string; +}): Promise<{ + classified: Pick; + message: string; + childStderrTail: string | null; +}> { + const { ctx, prepared, err, phase, messageOverride } = input; + const rawMessage = err instanceof Error ? err.message : String(err); + const message = messageOverride ?? rawMessage; + const classified = classifyError(err, phase); + const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath }); + if (childStderrTail) { + await ctx.onLog( + "stderr", + `[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`, + ); + } + await emitAcpxLog(ctx, { + type: "acpx.error", + message, + phase, + ...classified.errorMeta, + ...(childStderrTail ? { childStderrTail } : {}), + }); + return { classified, message, childStderrTail }; +} + function isResumeFailure(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return /resume|load|not found|no session|unknown session|conversation/i.test(message); @@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { permissionMode: prepared.permissionMode, nonInteractivePermissions: prepared.nonInteractivePermissions, timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined, + // Scope ACPX runtime verbose logs to the claude agent only — that's the + // surface we know needs the extra session-event detail (PAPA-388). codex + // and custom agents already emit their own per-tool output and don't + // benefit from doubling the log volume. + verbose: prepared.acpxAgent === "claude", }; const runtime = cached?.runtime ?? createRuntime(runtimeOptions); if (cached) clearWarmHandleTimer(cached); @@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { } } } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "ensure_session", + }); return { exitCode: 1, signal: null, @@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { onLog: ctx.onLog, }); } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "configure_session", + }); await runtime.close({ handle: sessionHandle, reason: "paperclip config cleanup", @@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { commandNotes: [ `ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`, `Effective ACPX permission mode: ${prepared.permissionMode}.`, - ...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []), + ...(prepared.requestedModel + ? [ + prepared.acpxAgent === "claude" + ? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).` + : `Requested ACPX model: ${prepared.requestedModel}.`, + ] + : []), ...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []), ...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []), ...(Array.isArray(prepared.skillsIdentity.commandNotes) @@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }; } catch (err) { if (timeout) clearTimeout(timeout); - const classified = classifyError(err); - const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err); + const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined; const cancel = cancelActiveTurn as ((reason: string) => Promise) | null; - if (cancel) await cancel(message).catch(() => {}); + const preEmitMessage = + messageOverride ?? (err instanceof Error ? err.message : String(err)); + if (cancel) await cancel(preEmitMessage).catch(() => {}); await runtime.close({ handle: sessionHandle, reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup", @@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { clearWarmHandleTimer(existing); warmHandles.delete(prepared.sessionKey); } - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "turn", + messageOverride, + }); return { exitCode: 1, signal: timedOut ? "SIGTERM" : null, diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 4b23fcb0..66e451cc 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -212,6 +212,14 @@ export async function testEnvironment( if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); if (extraArgs.length > 0) args.push(...extraArgs); + // Sandbox bridges still add lease warmup and transport overhead, but + // the standard-2 Cloudflare tier now probes fast enough that a 90s + // budget leaves headroom without masking real hangs. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45), + ); + const probe = await runAdapterExecutionTargetProcess( runId, target, @@ -220,7 +228,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {}, diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index d869dda8..a9c25fc0 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [ description: "Use the lowest-cost known Codex local model lane without changing the primary model.", adapterConfig: { model: "gpt-5.3-codex-spark", - modelReasoningEffort: "low", + // Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work. + modelReasoningEffort: "high", }, source: "adapter_default", }, diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts new file mode 100644 index 00000000..86483751 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { prepareManagedCodexHome } from "./codex-home.js"; + +describe("codex managed home", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("treats a concurrently-created expected auth symlink as success", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const managedAuth = path.join(managedCodexHome, "auth.json"); + + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + + const originalSymlink = fs.symlink.bind(fs); + vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => { + await originalSymlink(source, target, type); + const error = new Error("file already exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + }); + + try { + await expect( + prepareManagedCodexHome( + { + CODEX_HOME: sharedCodexHome, + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + }, + async () => {}, + "company-1", + ), + ).resolves.toBe(managedCodexHome); + + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 0cb737bb..ee87e6cb 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise { await fs.mkdir(path.dirname(target), { recursive: true }); } +async function isExpectedSymlink(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) return false; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return false; + + return path.resolve(path.dirname(target), linkedPath) === path.resolve(source); +} + +async function createExpectedSymlink(target: string, source: string): Promise { + try { + await fs.symlink(source, target); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" && await isExpectedSymlink(target, source)) return; + throw error; + } +} + async function ensureSymlink(target: string, source: string): Promise { const existing = await fs.lstat(target).catch(() => null); if (!existing) { await ensureParentDir(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); return; } @@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise { return; } - const linkedPath = await fs.readlink(target).catch(() => null); - if (!linkedPath) return; - - const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); - if (resolvedLinkedPath === source) return; + if (await isExpectedSymlink(target, source)) return; await fs.unlink(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); } async function ensureCopiedFile(target: string, source: string): Promise { diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index cc2c2916..8059d897 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -4,6 +4,7 @@ import type { AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; import { + asNumber, asString, asStringArray, parseObject, @@ -98,6 +99,7 @@ export async function testEnvironment( let command = asString(config.command, "agent"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -230,6 +232,12 @@ export async function testEnvironment( hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.", }); } else { + // Cursor's `agent` binary still pays cold-start overhead in container + // sandboxes, but standard-2 probes no longer need a 120s version budget. + const versionProbeTimeoutSec = Math.max( + 1, + asNumber(config.versionProbeTimeoutSec, targetIsSandbox ? 60 : 45), + ); const versionProbe = await runAdapterExecutionTargetProcess( runId, target, @@ -238,7 +246,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: versionProbeTimeoutSec, graceSec: 5, onLog: async () => {}, }, @@ -295,6 +303,12 @@ export async function testEnvironment( if (extraArgs.length > 0) args.push(...extraArgs); args.push("Respond with hello."); + // Sandbox bridges still add cursor CLI cold-start overhead, but the + // standard-2 tier now completes probes fast enough that 90s is ample. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45), + ); const probe = await runAdapterExecutionTargetProcess( runId, target, @@ -303,7 +317,7 @@ export async function testEnvironment( { cwd, env, - timeoutSec: 45, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, onLog: async () => {}, }, diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json new file mode 100644 index 00000000..466a6d49 --- /dev/null +++ b/packages/adapters/grok-local/package.json @@ -0,0 +1,60 @@ +{ + "name": "@paperclipai/adapter-grok-local", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/grok-local" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/grok-local/src/cli/format-event.test.ts b/packages/adapters/grok-local/src/cli/format-event.test.ts new file mode 100644 index 00000000..81ab879e --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { printGrokStreamEvent } from "./format-event.js"; + +describe("printGrokStreamEvent", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + afterEach(() => { + spy.mockClear(); + }); + + it("prints thought/text/end events", () => { + printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false); + printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false); + printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false); + + expect(spy.mock.calls.flat()).toEqual( + expect.arrayContaining([ + expect.stringContaining("thinking: Plan"), + expect.stringContaining("assistant: hello"), + expect.stringContaining("Grok run completed"), + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/cli/format-event.ts b/packages/adapters/grok-local/src/cli/format-event.ts new file mode 100644 index 00000000..951e5737 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/format-event.ts @@ -0,0 +1,59 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function printGrokStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type).trim(); + if (type === "thought") { + const text = asString(parsed.data); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "text") { + const text = asString(parsed.data); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason); + const sessionId = asString(parsed.sessionId); + const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""] + .filter(Boolean) + .join(" "); + console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`)); + return; + } + + if (type === "error") { + const text = + asString(parsed.data) || + asString(parsed.message) || + asString(parsed.error) || + "Grok error"; + console.log(pc.red(`error: ${text}`)); + return; + } + + const payload = asRecord(parsed); + console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`)); +} diff --git a/packages/adapters/grok-local/src/cli/index.ts b/packages/adapters/grok-local/src/cli/index.ts new file mode 100644 index 00000000..c6bdf988 --- /dev/null +++ b/packages/adapters/grok-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGrokStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/grok-local/src/index.ts b/packages/adapters/grok-local/src/index.ts new file mode 100644 index 00000000..7e12bd1d --- /dev/null +++ b/packages/adapters/grok-local/src/index.ts @@ -0,0 +1,45 @@ +export const type = "grok_local"; +export const label = "Grok Build (local)"; + +export const DEFAULT_GROK_LOCAL_MODEL = "grok-build"; + +export const models = [ + { id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL }, +]; + +export const agentConfigurationDoc = `# grok_local agent configuration + +Adapter: grok_local + +Use when: +- You want Paperclip to run the native Grok Build CLI locally on the host machine +- You want resumable Grok sessions across heartbeats via \`--resume\` +- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`) + +Don't use when: +- You need a webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Grok CLI is not installed or authenticated on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\` +- promptTemplate (string, optional): run prompt template +- model (string, optional): Grok model id. Defaults to grok-build. +- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\` +- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\` +- maxTurns (number, optional): maximum agent turns for the run +- command (string, optional): defaults to "grok" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use \`grok --single\` with \`--output-format streaming-json\`. +- Sessions resume with \`--resume \` when the saved session cwd matches the current cwd. +- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills. +- Use \`grok models\` to inspect authentication and available models on the host. +`; diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts new file mode 100644 index 00000000..b520bf13 --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ + workspaceRemoteDir: null, + restoreWorkspace: async () => {}, +}))); +const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok")); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + adapterExecutionTargetIsRemote: () => false, + adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd, + overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target, + adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }), + adapterExecutionTargetSessionMatches: () => true, + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock, + prepareAdapterExecutionTargetRuntime: prepareRuntimeMock, + readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" }, + resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock, + resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { execute } from "./execute.js"; + +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-")); + tempRoots.push(root); + return root; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +describe("grok_local execute", () => { + beforeEach(() => { + ensureRuntimeInstalledMock.mockClear(); + ensureCommandMock.mockClear(); + prepareRuntimeMock.mockClear(); + resolveCommandForLogsMock.mockClear(); + runProcessMock.mockReset(); + }); + + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); + }); + + it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + + runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => { + expect(args).toEqual( + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + ]), + ); + expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok."); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true); + await options.onLog?.("stdout", '{"type":"text","data":"done"}\n'); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "done" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }; + }); + + const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const ctx: AdapterExecutionContext = { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async (stream: "stdout" | "stderr", chunk: string) => { + logs.push({ stream, chunk }); + }, + }; + + const result = await execute(ctx); + + expect(result).toMatchObject({ + exitCode: 0, + errorMessage: null, + summary: "done", + sessionId: "sess-1", + sessionDisplayId: "sess-1", + }); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + expect(logs.map((entry) => entry.chunk)).not.toEqual([]); + }); + + it("cleans up staged assets when setup fails before the Grok process starts", async () => { + const root = await makeTempRoot(); + const instructionsPath = path.join(root, "managed", "AGENTS.md"); + const skillSource = path.join(root, "runtime-skills", "paperclip"); + await fs.mkdir(path.dirname(instructionsPath), { recursive: true }); + await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8"); + await fs.mkdir(skillSource, { recursive: true }); + await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8"); + ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed")); + + const ctx: AdapterExecutionContext = { + runId: "run-setup-fail", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + cwd: root, + instructionsFilePath: instructionsPath, + paperclipRuntimeSkills: [{ + key: "paperclip", + runtimeName: "paperclip", + source: skillSource, + required: false, + }], + paperclipSkillSync: { desiredSkills: ["paperclip"] }, + }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + }; + + await expect(execute(ctx)).rejects.toThrow("grok not installed"); + expect(runProcessMock).not.toHaveBeenCalled(); + expect(await pathExists(path.join(root, "Agents.md"))).toBe(false); + expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts new file mode 100644 index 00000000..0d25845a --- /dev/null +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -0,0 +1,583 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + overrideAdapterExecutionTargetRemoteCwd, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + resolveAdapterExecutionTargetTimeoutSec, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildInvocationEnvForLogs, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensurePathInEnv, + joinPromptSections, + materializePaperclipSkillCopy, + parseObject, + readPaperclipIssueWorkModeFromContext, + readPaperclipRuntimeSkillEntries, + renderTemplate, + renderPaperclipWakePrompt, + resolvePaperclipDesiredSkillNames, + stringifyPaperclipWakePayload, + refreshPaperclipWorkspaceEnvForExecution, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use shell commands with curl to make Paperclip API requests when needed.", + "Include X-Paperclip-Run-Id on mutating requests.", + "", + "", + ].join("\n"); +} + +type StageCleanup = { + kind: "file" | "dir"; + path: string; +}; + +type StagedGrokAssets = { + cleanup: () => Promise; + stagedSkillsCount: number; + stagedInstructionsPath: string | null; + rulesFilePath: string | null; +}; + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function stageGrokProjectAssets(input: { + cwd: string; + instructionsFilePath: string; + skillEntries: Array<{ key: string; runtimeName: string; source: string }>; + desiredSkillNames: string[]; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const cleanup: StageCleanup[] = []; + const ensureCleanupDir = (candidate: string) => { + cleanup.push({ kind: "dir", path: candidate }); + }; + const ensureCleanupFile = (candidate: string) => { + cleanup.push({ kind: "file", path: candidate }); + }; + + let stagedInstructionsPath: string | null = null; + let rulesFilePath: string | null = null; + let stagedSkillsCount = 0; + + const instructionsTarget = path.join(input.cwd, "Agents.md"); + if (input.instructionsFilePath) { + if (!await pathExists(instructionsTarget)) { + await fs.copyFile(input.instructionsFilePath, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) { + rulesFilePath = input.instructionsFilePath; + await input.onLog( + "stdout", + `[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`, + ); + } + } else { + const canonicalAgents = path.join(input.cwd, "AGENTS.md"); + if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) { + await fs.copyFile(canonicalAgents, instructionsTarget); + ensureCleanupFile(instructionsTarget); + stagedInstructionsPath = instructionsTarget; + } + } + + const desiredSet = new Set(input.desiredSkillNames); + const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key)); + if (selectedSkills.length > 0) { + const claudeDir = path.join(input.cwd, ".claude"); + const skillsRoot = path.join(claudeDir, "skills"); + if (!await pathExists(claudeDir)) { + await fs.mkdir(claudeDir, { recursive: true }); + ensureCleanupDir(claudeDir); + } + if (!await pathExists(skillsRoot)) { + await fs.mkdir(skillsRoot, { recursive: true }); + ensureCleanupDir(skillsRoot); + } + + for (const skill of selectedSkills) { + const target = path.join(skillsRoot, skill.runtimeName); + if (await pathExists(target)) { + await input.onLog( + "stdout", + `[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`, + ); + continue; + } + await materializePaperclipSkillCopy(skill.source, target); + ensureCleanupDir(target); + stagedSkillsCount += 1; + } + } + + return { + stagedSkillsCount, + stagedInstructionsPath, + rulesFilePath, + cleanup: async () => { + for (const entry of [...cleanup].reverse()) { + if (entry.kind === "file") { + await fs.rm(entry.path, { force: true }).catch(() => undefined); + continue; + } + await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined); + } + }, + }; +} + +function resolveBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription"; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + + const promptTemplate = asString( + config.promptTemplate, + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + ); + const command = asString(config.command, "grok"); + const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk"; + const reasoningEffort = asString(config.reasoningEffort, "").trim(); + const maxTurns = asNumber(config.maxTurns, 0); + const alwaysApprove = asBoolean(config.alwaysApprove, true); + const disableWebSearch = asBoolean(config.disableWebSearch, true); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value: unknown): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const stagedAssets = await stageGrokProjectAssets({ + cwd, + instructionsFilePath, + skillEntries: grokSkillEntries, + desiredSkillNames: desiredGrokSkillNames, + onLog, + }); + let restoreRemoteWorkspace: (() => Promise) | null = null; + + try { + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + + if (executionTargetIsRemote) { + await onLog( + "stdout", + `[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "grok", + workspaceLocalDir: cwd, + timeoutSec, + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command, + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + } + + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + const effectiveEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const runtimeEnv = ensurePathInEnv(effectiveEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { + installCommand: ctx.runtimeCommandSpec?.installCommand ?? null, + timeoutSec, + }); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + const billingType = resolveBillingType(effectiveEnv); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, + ); + } + + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."]; + if (alwaysApprove) notes.push("Added --always-approve for unattended execution."); + if (stagedAssets.stagedInstructionsPath) { + notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`); + } + if (stagedAssets.rulesFilePath) { + notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`); + } + if (stagedAssets.stagedSkillsCount > 0) { + notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`); + } + return notes; + })(); + + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + wakePrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model); + if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort); + if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); + if (permissionMode) args.push("--permission-mode", permissionMode); + if (alwaysApprove) args.push("--always-approve"); + if (disableWebSearch) args.push("--disable-web-search"); + if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("--single", prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "grok_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + parsed: parseGrokJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const failed = (attempt.proc.exitCode ?? 0) !== 0; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const fallbackErrorMessage = + parsedError || + stderrLine || + `Grok exited with code ${attempt.proc.exitCode ?? -1}`; + + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + } as Record) + : null; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: failed ? fallbackErrorMessage : null, + usage: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "xai", + biller: billingType === "api" ? "xai" : "grok", + model, + billingType, + costUsd: null, + resultJson: { + stopReason: attempt.parsed.stopReason, + requestId: attempt.parsed.requestId, + ...(failed ? { stderr: attempt.proc.stderr } : {}), + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stdout", + `[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + restoreRemoteWorkspace?.(), + stagedAssets.cleanup(), + ]); + } +} diff --git a/packages/adapters/grok-local/src/server/index.ts b/packages/adapters/grok-local/src/server/index.ts new file mode 100644 index 00000000..127128cf --- /dev/null +++ b/packages/adapters/grok-local/src/server/index.ts @@ -0,0 +1,66 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; + +export { execute } from "./execute.js"; +export { listGrokSkills, syncGrokSkills } from "./skills.js"; +export { testEnvironment } from "./test.js"; +export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/grok-local/src/server/parse.test.ts b/packages/adapters/grok-local/src/server/parse.test.ts new file mode 100644 index 00000000..213f8c02 --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; + +describe("parseGrokJsonl", () => { + it("collects streamed thought/text content and final session metadata", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "thought", data: "Plan" }), + JSON.stringify({ type: "thought", data: " first." }), + JSON.stringify({ type: "text", data: "hel" }), + JSON.stringify({ type: "text", data: "lo" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n")); + + expect(parsed).toEqual({ + sessionId: "sess-1", + summary: "hello", + thought: "Plan first.", + errorMessage: null, + stopReason: "EndTurn", + requestId: "req-1", + }); + }); + + it("reads structured error payloads", () => { + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "error", error: { message: "Authentication required" } }), + ].join("\n")); + + expect(parsed.errorMessage).toBe("Authentication required"); + }); + + it("separates reasoning turns that grok streaming-json glues together", () => { + // PAPA-349: at turn boundaries grok drops the newline between turns; the + // aggregated thought should still read as two paragraphs. + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "thought", data: "The user uses `" }), + JSON.stringify({ type: "thought", data: "ls" }), + JSON.stringify({ type: "thought", data: "`" }), + JSON.stringify({ type: "thought", data: "The" }), + JSON.stringify({ type: "thought", data: " `" }), + JSON.stringify({ type: "thought", data: "ls" }), + JSON.stringify({ type: "thought", data: "`" }), + JSON.stringify({ type: "thought", data: " returned" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), + ].join("\n")); + + expect(parsed.thought).toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("preserves assistant `text` chunks verbatim (no boundary heuristic)", () => { + // PAPA-349 review feedback: the turn-boundary helper is scoped to the + // reasoning stream only. Final assistant text is stored unmodified so + // user-visible responses cannot be reshaped by the heuristic. + const parsed = parseGrokJsonl([ + JSON.stringify({ type: "text", data: "Done." }), + JSON.stringify({ type: "text", data: "Next" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), + ].join("\n")); + + expect(parsed.summary).toBe("Done.Next"); + }); +}); + +describe("isGrokUnknownSessionError", () => { + it("detects stale resume failures", () => { + expect(isGrokUnknownSessionError("", "session not found")).toBe(true); + expect(isGrokUnknownSessionError("", "everything fine")).toBe(false); + }); +}); diff --git a/packages/adapters/grok-local/src/server/parse.ts b/packages/adapters/grok-local/src/server/parse.ts new file mode 100644 index 00000000..7fc415a2 --- /dev/null +++ b/packages/adapters/grok-local/src/server/parse.ts @@ -0,0 +1,89 @@ +import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { applyTurnBoundary, createTurnBoundaryState } from "../shared/turn-boundary.js"; + +export interface ParsedGrokJsonl { + sessionId: string | null; + summary: string; + thought: string; + errorMessage: string | null; + stopReason: string | null; + requestId: string | null; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "").trim() || + asString(rec.error, "").trim() || + asString(rec.detail, "").trim() || + asString(rec.code, "").trim(); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseGrokJsonl(stdout: string): ParsedGrokJsonl { + let sessionId: string | null = null; + let stopReason: string | null = null; + let requestId: string | null = null; + let errorMessage: string | null = null; + const thoughtParts: string[] = []; + const textParts: string[] = []; + const thoughtBoundary = createTurnBoundaryState(); + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const type = asString(event.type, "").trim(); + if (type === "thought") { + const text = asString(event.data, ""); + if (text) thoughtParts.push(applyTurnBoundary(thoughtBoundary, text)); + continue; + } + + if (type === "text") { + const text = asString(event.data, ""); + if (text) textParts.push(text); + continue; + } + + if (type === "end") { + sessionId = asString(event.sessionId, "").trim() || sessionId; + stopReason = asString(event.stopReason, "").trim() || stopReason; + requestId = asString(event.requestId, "").trim() || requestId; + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim(); + if (text) errorMessage = text; + } + } + + return { + sessionId, + summary: textParts.join("").trim(), + thought: thoughtParts.join("").trim(), + errorMessage, + stopReason, + requestId, + }; +} + +export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack); +} diff --git a/packages/adapters/grok-local/src/server/skills.ts b/packages/adapters/grok-local/src/server/skills.ts new file mode 100644 index 00000000..fdbbb548 --- /dev/null +++ b/packages/adapters/grok-local/src/server/skills.ts @@ -0,0 +1,80 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildGrokSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be copied into `.claude/skills` in the execution workspace on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "grok_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listGrokSkills(ctx: AdapterSkillContext): Promise { + return buildGrokSkillSnapshot(ctx.config); +} + +export async function syncGrokSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildGrokSkillSnapshot(ctx.config); +} diff --git a/packages/adapters/grok-local/src/server/test.test.ts b/packages/adapters/grok-local/src/server/test.test.ts new file mode 100644 index 00000000..6800744b --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runProcessMock = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ + describeAdapterExecutionTarget: () => "local", + ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock, + ensureAdapterExecutionTargetDirectory: ensureDirectoryMock, + resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) => + configuredCwd || fallbackCwd, + runAdapterExecutionTargetProcess: runProcessMock, +})); + +import { parseGrokModelsOutput, testEnvironment } from "./test.js"; + +describe("parseGrokModelsOutput", () => { + it("extracts auth state and models from `grok models` output", () => { + expect(parseGrokModelsOutput([ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + " * grok-code", + ].join("\n"))).toEqual({ + authenticated: true, + defaultModel: "grok-build", + models: ["grok-build", "grok-code"], + }); + }); +}); + +describe("grok_local testEnvironment", () => { + beforeEach(() => { + ensureDirectoryMock.mockClear(); + ensureCommandMock.mockClear(); + runProcessMock.mockReset(); + }); + + it("reports a healthy authenticated host with a working hello probe", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + "You are logged in with grok.com.", + "", + "Default model: grok-build", + "", + "Available models:", + " * grok-build (default)", + ].join("\n"), + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "hello" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + model: "grok-build", + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_command_resolvable", + "grok_models_probe_passed", + "grok_model_configured", + "grok_hello_probe_passed", + ]), + ); + expect(runProcessMock).toHaveBeenNthCalledWith( + 2, + expect.any(String), + null, + "grok", + expect.arrayContaining([ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + "--single", + "Respond with exactly hello.", + ]), + expect.any(Object), + ); + }); + + it("downgrades auth failures to warnings", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { + command: "grok", + cwd: "/tmp/project", + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks.map((check: { code: string }) => check.code)).toEqual( + expect.arrayContaining([ + "grok_auth_required", + "grok_hello_probe_auth_required", + ]), + ); + }); +}); diff --git a/packages/adapters/grok-local/src/server/test.ts b/packages/adapters/grok-local/src/server/test.ts new file mode 100644 index 00000000..b69ad7ff --- /dev/null +++ b/packages/adapters/grok-local/src/server/test.ts @@ -0,0 +1,313 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asNumber, + asString, + asStringArray, + ensurePathInEnv, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + resolveAdapterExecutionTargetCwd, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { parseGrokJsonl } from "./parse.js"; + +export interface GrokModelsProbe { + authenticated: boolean; + defaultModel: string | null; + models: string[]; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +const GROK_AUTH_REQUIRED_RE = + /(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i; + +export function parseGrokModelsOutput(stdout: string): GrokModelsProbe { + const trimmedLines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const models: string[] = []; + let defaultModel: string | null = null; + let authenticated = false; + let inModelsBlock = false; + + for (const line of trimmedLines) { + if (/logged in/i.test(line)) authenticated = true; + const defaultMatch = /^Default model:\s*(.+)$/i.exec(line); + if (defaultMatch?.[1]) { + defaultModel = defaultMatch[1].trim(); + continue; + } + if (/^Available models:/i.test(line)) { + inModelsBlock = true; + continue; + } + if (!inModelsBlock) continue; + const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line); + if (bulletMatch?.[1]) { + models.push(bulletMatch[1].trim()); + continue; + } + if (line.length > 0) { + models.push(line.replace(/\s+\(default\)$/, "").trim()); + } + } + + return { + authenticated, + defaultModel, + models: Array.from(new Set(models.filter(Boolean))), + }; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "grok"); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) + : null; + const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "grok_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } + + try { + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); + checks.push({ + code: "grok_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "grok_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const env = normalizeEnv(config.env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + + try { + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); + checks.push({ + code: "grok_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "grok_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable"); + + const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim(); + + if (canRunProbe) { + const modelsProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + ["models"], + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + + const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`; + const parsedModels = parseGrokModelsOutput(modelsProbe.stdout); + const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput); + + if (modelsProbe.timedOut) { + checks.push({ + code: "grok_models_probe_timed_out", + level: "warn", + message: "`grok models` timed out.", + hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.", + }); + } else if ((modelsProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_auth_required" : "grok_models_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI is not authenticated." + : "`grok models` failed.", + detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else { + checks.push({ + code: "grok_models_probe_passed", + level: "info", + message: parsedModels.authenticated + ? "Grok CLI authentication is configured." + : "`grok models` completed.", + detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined, + }); + if (parsedModels.models.length > 0) { + checks.push({ + code: "grok_models_discovered", + level: "info", + message: `Discovered ${parsedModels.models.length} Grok model(s).`, + }); + } else { + checks.push({ + code: "grok_models_empty", + level: "warn", + message: "Grok returned no available models.", + hint: "Run `grok models` manually and verify the account has access to a model.", + }); + } + if (configuredModel) { + checks.push({ + code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found", + level: parsedModels.models.includes(configuredModel) ? "info" : "warn", + message: parsedModels.models.includes(configuredModel) + ? `Configured model: ${configuredModel}` + : `Configured model "${configuredModel}" not found in available models.`, + hint: parsedModels.models.includes(configuredModel) + ? undefined + : "Run `grok models` and choose an available model id.", + }); + } + } + } + + if (canRunProbe) { + const probeArgs = [ + "--output-format", + "streaming-json", + "--always-approve", + "--permission-mode", + "dontAsk", + "--disable-web-search", + ]; + if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) { + probeArgs.push("--model", configuredModel); + } + probeArgs.push("--single", "Respond with exactly hello."); + + const helloProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + probeArgs, + { + cwd, + env, + timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)), + graceSec: 5, + onLog: async () => {}, + }, + ); + const parsed = parseGrokJsonl(helloProbe.stdout); + const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage); + const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`); + + if (helloProbe.timedOut) { + checks.push({ + code: "grok_hello_probe_timed_out", + level: "warn", + message: "Grok hello probe timed out.", + hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.", + }); + } else if ((helloProbe.exitCode ?? 1) !== 0) { + checks.push({ + code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed", + level: authRequired ? "warn" : "error", + message: authRequired + ? "Grok CLI could not answer the hello probe because authentication is missing." + : "Grok hello probe failed.", + ...(detail ? { detail } : {}), + hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, + }); + } else if (/\bhello\b/i.test(parsed.summary)) { + checks.push({ + code: "grok_hello_probe_passed", + level: "info", + message: "Grok hello probe succeeded.", + }); + } else { + checks.push({ + code: "grok_hello_probe_unexpected_output", + level: "warn", + message: "Grok hello probe succeeded but returned unexpected output.", + ...(detail ? { detail } : {}), + }); + } + } + + return { + adapterType: "grok_local", + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/grok-local/src/shared/turn-boundary.test.ts b/packages/adapters/grok-local/src/shared/turn-boundary.test.ts new file mode 100644 index 00000000..1fc27acf --- /dev/null +++ b/packages/adapters/grok-local/src/shared/turn-boundary.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js"; + +function run(chunks: string[]): string { + const state = createTurnBoundaryState(); + return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join(""); +} + +describe("applyTurnBoundary", () => { + it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => { + expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"])) + .toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => { + expect(run(["returned", ":", "Confirmed", ":", " 4 files"])) + .toBe("returned:\nConfirmed: 4 files"); + }); + + it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => { + expect(run(["render `", "React", "` then "])) + .toBe("render `React` then "); + }); + + it("leaves natural token streams with proper whitespace alone", () => { + expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"])) + .toBe("The user wants me to:\n1. List"); + }); + + it("does not insert a separator when the next chunk starts with whitespace", () => { + expect(run(["function", ".", " They"])) + .toBe("function. They"); + }); + + it("does not insert a separator when the next chunk starts lowercase", () => { + expect(run(["`", "ls", "`"])) + .toBe("`ls`"); + }); + + it("does not insert a separator when the next chunk is a single character", () => { + expect(run([":", "A"])) + .toBe(":A"); + }); + + it("does not insert a separator after a self-contained backtick span in a single chunk", () => { + // Greptile review: a chunk like "`ls`" is a balanced span; the following + // capitalized word should be treated as a continuation, not a new turn. + expect(run(["`ls`", "Then"])) + .toBe("`ls`Then"); + }); +}); diff --git a/packages/adapters/grok-local/src/shared/turn-boundary.ts b/packages/adapters/grok-local/src/shared/turn-boundary.ts new file mode 100644 index 00000000..267b784e --- /dev/null +++ b/packages/adapters/grok-local/src/shared/turn-boundary.ts @@ -0,0 +1,54 @@ +// Grok's `--output-format streaming-json` mode emits `thought` and `text` events +// token-by-token. Between reasoning turns (around tool calls) it drops the `\n` +// separator that the non-streaming `--output-format json` mode includes in the +// aggregated `thought` field. This helper inserts a single `\n` when a new chunk +// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``). + +export interface TurnBoundaryState { + lastChunk: string; + backtickParity: 0 | 1; +} + +export function createTurnBoundaryState(): TurnBoundaryState { + return { lastChunk: "", backtickParity: 0 }; +} + +function countBackticks(text: string): number { + let count = 0; + for (const ch of text) if (ch === "`") count += 1; + return count; +} + +function endsWithSentenceClose(ch: string): boolean { + return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";"; +} + +export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string { + if (!incoming) return incoming; + + let output = incoming; + const prev = state.lastChunk; + if ( + prev && + !/\s$/.test(prev) && + !/^\s/.test(incoming) && + /^[A-Z]/.test(incoming) && + incoming.length >= 2 + ) { + const lastChar = prev[prev.length - 1]!; + // Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the + // stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a + // self-contained span and the following capitalized word is a continuation, + // not a new turn. + const closingLoneBacktick = + prev === "`" && state.backtickParity === 0; + const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick; + if (looksLikeNewTurn) { + output = `\n${incoming}`; + } + } + + state.lastChunk = incoming; + state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1; + return output; +} diff --git a/packages/adapters/grok-local/src/ui/build-config.test.ts b/packages/adapters/grok-local/src/ui/build-config.test.ts new file mode 100644 index 00000000..118a7494 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildGrokLocalConfig } from "./build-config.js"; + +describe("buildGrokLocalConfig", () => { + it("maps create-form values into adapter config", () => { + expect(buildGrokLocalConfig({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + thinkingEffort: "high", + envVars: "XAI_API_KEY=secret\n", + extraArgs: "--check, --verbatim", + } as never)).toEqual({ + cwd: "/tmp/project", + instructionsFilePath: "/tmp/AGENTS.md", + model: "grok-build", + timeoutSec: 0, + graceSec: 20, + reasoningEffort: "high", + env: { + XAI_API_KEY: { type: "plain", value: "secret" }, + }, + extraArgs: ["--check", "--verbatim"], + }); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/build-config.ts b/packages/adapters/grok-local/src/ui/build-config.ts new file mode 100644 index 00000000..6c9e9a66 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/build-config.ts @@ -0,0 +1,74 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGrokLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 20; + if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/grok-local/src/ui/index.ts b/packages/adapters/grok-local/src/ui/index.ts new file mode 100644 index 00000000..605accd8 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGrokStdoutLine, createGrokStdoutParser } from "./parse-stdout.js"; +export { buildGrokLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.test.ts b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000..96276149 --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js"; + +describe("parseGrokStdoutLine", () => { + const ts = "2026-05-15T00:00:00.000Z"; + + it("maps thought/text/end events into transcript entries", () => { + expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([ + { kind: "thinking", ts, text: "Plan first.", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([ + { kind: "assistant", ts, text: "hello", delta: true }, + ]); + expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([ + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); + + it("surfaces structured Grok error payload text", () => { + expect(parseGrokStdoutLine(JSON.stringify({ + type: "error", + error: { message: "Authentication required" }, + }), ts)).toEqual([ + { kind: "stderr", ts, text: "Authentication required" }, + ]); + }); +}); + +describe("createGrokStdoutParser", () => { + const ts = "2026-05-15T00:00:00.000Z"; + + function thoughtTexts(chunks: string[]): string { + const parser = createGrokStdoutParser(); + return chunks + .map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts)) + .flat() + .map((entry) => entry.kind === "thinking" ? entry.text : "") + .join(""); + } + + it("inserts a newline between reasoning turns that grok streaming-json glues together", () => { + // Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned" + expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"])) + .toBe("The user uses `ls`\nThe `ls` returned"); + }); + + it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => { + expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"])) + .toBe("returned:\nConfirmed: 4 files"); + }); + + it("resets state between independent transcript builds", () => { + const parser = createGrokStdoutParser(); + parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts); + parser.reset(); + expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([ + { kind: "thinking", ts, text: "Second", delta: true }, + ]); + }); + + it("does not modify assistant `text` chunks", () => { + // PAPA-349 review feedback: keep final assistant text streaming verbatim; + // the boundary heuristic is scoped to reasoning. + const parser = createGrokStdoutParser(); + parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts); + expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([ + { kind: "assistant", ts, text: "Next", delta: true }, + ]); + }); +}); diff --git a/packages/adapters/grok-local/src/ui/parse-stdout.ts b/packages/adapters/grok-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..476dd88a --- /dev/null +++ b/packages/adapters/grok-local/src/ui/parse-stdout.ts @@ -0,0 +1,87 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { applyTurnBoundary, createTurnBoundaryState, type TurnBoundaryState } from "../shared/turn-boundary.js"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function extractErrorText(value: unknown): string { + if (typeof value === "string") return value; + const record = asRecord(value); + if (!record) return ""; + return asString(record.message) || asString(record.detail) || asString(record.code); +} + +function parseLineInternal( + line: string, + ts: string, + thoughtBoundary: TurnBoundaryState, +): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type).trim(); + + if (type === "thought") { + const text = asString(parsed.data); + if (!text) return []; + return [{ kind: "thinking", ts, text: applyTurnBoundary(thoughtBoundary, text), delta: true }]; + } + + if (type === "text") { + const text = asString(parsed.data); + if (!text) return []; + return [{ kind: "assistant", ts, text, delta: true }]; + } + + if (type === "error") { + const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error); + return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }]; + } + + if (type === "end") { + const stopReason = asString(parsed.stopReason).trim(); + const sessionId = asString(parsed.sessionId).trim(); + const parts = [ + stopReason ? `stop_reason=${stopReason}` : "", + sessionId ? `session=${sessionId}` : "", + ].filter(Boolean); + return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }]; + } + + return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }]; +} + +export function createGrokStdoutParser() { + let thoughtBoundary = createTurnBoundaryState(); + return { + parseLine(line: string, ts: string): TranscriptEntry[] { + return parseLineInternal(line, ts, thoughtBoundary); + }, + reset() { + thoughtBoundary = createTurnBoundaryState(); + }, + }; +} + +// Stateless fallback for callers that haven't migrated to the stateful factory. +// Without state, consecutive thought chunks at reasoning-turn boundaries can +// still appear merged; prefer createGrokStdoutParser for live transcripts. +export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] { + return parseLineInternal(line, ts, createTurnBoundaryState()); +} diff --git a/packages/adapters/grok-local/tsconfig.json b/packages/adapters/grok-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/grok-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 7335fda1..634d920c 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -9,6 +9,7 @@ import type { import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import { asBoolean, + asNumber, asString, asStringArray, parseObject, @@ -72,6 +73,7 @@ export async function testEnvironment( const command = asString(config.command, "opencode"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -334,6 +336,14 @@ export async function testEnvironment( if (variant) args.push("--variant", variant); if (extraArgs.length > 0) args.push(...extraArgs); + // Sandbox bridges still add cold-start and transport overhead, but the + // standard-2 Cloudflare tier now probes quickly enough that 90s keeps + // useful headroom without letting slow hangs linger. + const helloProbeTimeoutSec = Math.max( + 1, + asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 60), + ); + try { const probe = await runAdapterExecutionTargetProcess( runId, @@ -343,7 +353,7 @@ export async function testEnvironment( { cwd: runtimeCwd, env: runtimeEnv, - timeoutSec: 60, + timeoutSec: helloProbeTimeoutSec, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {}, diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 4d13eb76..fef8bc2b 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -3,7 +3,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "pi_local"; export const label = "Pi (local)"; -export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent"; +export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0"; export const models: Array<{ id: string; label: string }> = []; diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 8a83a8f4..5cabb4d0 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -309,6 +309,107 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { 60_000, ); + it( + "preserves composite foreign key column order without duplicate referenced columns", + async () => { + const sourceConnectionString = await createTempDatabase(); + const restoreConnectionString = await createSiblingDatabase( + sourceConnectionString, + "paperclip_composite_fk_restore_target", + ); + const backupDir = createTempDir("paperclip-db-composite-fk-backup-"); + const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} }); + const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} }); + + try { + await sourceSql.unsafe(` + CREATE SCHEMA "plugin_composite_fk"; + CREATE TABLE "plugin_composite_fk"."content_cases" ( + "id" uuid PRIMARY KEY, + "company_id" uuid NOT NULL, + "title" text NOT NULL, + CONSTRAINT "content_cases_company_case_unique" UNIQUE ("company_id", "id") + ); + CREATE TABLE "plugin_composite_fk"."content_case_signals" ( + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "signal" text NOT NULL, + "scopes" text[] NOT NULL, + "warnings" jsonb DEFAULT '[]'::jsonb NOT NULL, + CONSTRAINT "content_case_signals_company_case" + FOREIGN KEY ("company_id", "case_id") + REFERENCES "plugin_composite_fk"."content_cases" ("company_id", "id") + ON DELETE CASCADE + ); + INSERT INTO "plugin_composite_fk"."content_cases" ("company_id", "id", "title") + VALUES ( + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + 'case' + ); + INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes", "warnings") + VALUES ( + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + 'signal', + ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'], + jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"')) + ); + `); + + const result = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, + filenamePrefix: "paperclip-composite-fk-test", + backupEngine: "javascript", + }); + + await runDatabaseRestore({ + connectionString: restoreConnectionString, + backupFile: result.backupFile, + }); + + const rows = await restoreSql.unsafe<{ + signal: string; + title: string; + scopes: string[]; + warnings: Array; + }[]>(` + SELECT s."signal", c."title", s."scopes", s."warnings" + FROM "plugin_composite_fk"."content_case_signals" s + JOIN "plugin_composite_fk"."content_cases" c + ON c."company_id" = s."company_id" + AND c."id" = s."case_id" + `); + expect(rows).toEqual([ + { + signal: "signal", + title: "case", + scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"], + warnings: ["json warning", { code: 'quoted "value"' }], + }, + ]); + + await expect( + restoreSql.unsafe(` + INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes") + VALUES ( + '11111111-1111-4111-8111-111111111111', + '33333333-3333-4333-8333-333333333333', + 'orphan', + ARRAY[]::text[] + ) + `), + ).rejects.toThrow(); + } finally { + await sourceSql.end(); + await restoreSql.end(); + } + }, + 60_000, + ); + it( "restores legacy public-only backups without migration history", async () => { diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 2ae92517..02dc168b 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -249,12 +249,39 @@ function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean { Object.keys(opts.nullifyColumns ?? {}).length > 0; } -function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set): string { +function formatPostgresArrayElement(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (Array.isArray(value)) return formatPostgresArrayLiteral(value); + const raw = value instanceof Date + ? value.toISOString() + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + if (raw.length === 0 || /^null$/i.test(raw) || /[{}\s,"\\]/.test(raw)) { + return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + } + return raw; +} + +function formatPostgresArrayLiteral(value: unknown[]): string { + return `{${value.map(formatPostgresArrayElement).join(",")}}`; +} + +function formatSqlValue( + rawValue: unknown, + columnName: string | undefined, + nullifiedColumns: Set, + dataType?: string, +): string { const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; if (val === null || val === undefined) return "NULL"; + if (dataType === "json" || dataType === "jsonb") { + return formatSqlLiteral(JSON.stringify(val)); + } if (typeof val === "boolean") return val ? "true" : "false"; if (typeof val === "number") return String(val); if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (Array.isArray(val)) return formatSqlLiteral(formatPostgresArrayLiteral(val)); if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); return formatSqlLiteral(String(val)); } @@ -745,58 +772,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } - // Foreign keys (after all tables created) - const allForeignKeys = await sql<{ - constraint_name: string; - source_schema: string; - source_table: string; - source_columns: string[]; - target_schema: string; - target_table: string; - target_columns: string[]; - update_rule: string; - delete_rule: string; - }[]>` - SELECT - c.conname AS constraint_name, - srcn.nspname AS source_schema, - src.relname AS source_table, - array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns, - tgtn.nspname AS target_schema, - tgt.relname AS target_table, - array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns, - CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, - CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule - FROM pg_constraint c - JOIN pg_class src ON src.oid = c.conrelid - JOIN pg_namespace srcn ON srcn.oid = src.relnamespace - JOIN pg_class tgt ON tgt.oid = c.confrelid - JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace - JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey) - JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey) - WHERE c.contype = 'f' - AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))} - GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype - ORDER BY srcn.nspname, src.relname, c.conname - `; - const fks = allForeignKeys.filter( - (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table)) - && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), - ); - - if (fks.length > 0) { - emit("-- Foreign keys"); - for (const fk of fks) { - const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); - const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); - emitStatement( - `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, - ); - } - emit(""); - } - - // Unique constraints + // Unique constraints must exist before foreign keys that reference them. const allUniqueConstraints = await sql<{ constraint_name: string; schema_name: string; @@ -827,6 +803,58 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } + // Foreign keys (after all tables and referenced unique constraints are created) + const allForeignKeys = await sql<{ + constraint_name: string; + source_schema: string; + source_table: string; + source_columns: string[]; + target_schema: string; + target_table: string; + target_columns: string[]; + update_rule: string; + delete_rule: string; + }[]>` + SELECT + c.conname AS constraint_name, + srcn.nspname AS source_schema, + src.relname AS source_table, + array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns, + tgtn.nspname AS target_schema, + tgt.relname AS target_table, + array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns, + CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule, + CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule + FROM pg_constraint c + JOIN pg_class src ON src.oid = c.conrelid + JOIN pg_namespace srcn ON srcn.oid = src.relnamespace + JOIN pg_class tgt ON tgt.oid = c.confrelid + JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace + JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true + JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum + JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum + WHERE c.contype = 'f' + AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))} + GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype + ORDER BY srcn.nspname, src.relname, c.conname + `; + const fks = allForeignKeys.filter( + (fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table)) + && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), + ); + + if (fks.length > 0) { + emit("-- Foreign keys"); + for (const fk of fks) { + const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); + const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); + emitStatement( + `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, + ); + } + emit(""); + } + // Indexes (non-primary, non-unique-constraint) const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>` SELECT schemaname AS schema_name, tablename, indexdef @@ -895,7 +923,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise for await (const rows of rowCursor) { for (const row of rows) { const values = row.map((rawValue, index) => - formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns), + formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type), ); emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); } diff --git a/packages/db/src/embedded-postgres-native.test.ts b/packages/db/src/embedded-postgres-native.test.ts new file mode 100644 index 00000000..7335f2c6 --- /dev/null +++ b/packages/db/src/embedded-postgres-native.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js"; + +describe("embedded Postgres native runtime", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== "win32")("creates soname aliases for bundled patch-level shared libraries", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-")); + tempDirs.push(tempDir); + fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), ""); + fs.writeFileSync(path.join(tempDir, "libicui18n.so.60.2"), ""); + fs.writeFileSync(path.join(tempDir, "README.md"), ""); + + const created = await ensureLinuxSharedLibraryAliases(tempDir); + + expect(created.map((file) => path.basename(file)).sort()).toEqual([ + "libicui18n.so.60", + "libicuuc.so.60", + ]); + expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2"); + }); + + it.runIf(process.platform !== "win32")("is idempotent when aliases already exist", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-")); + tempDirs.push(tempDir); + fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), ""); + + await ensureLinuxSharedLibraryAliases(tempDir); + const second = await ensureLinuxSharedLibraryAliases(tempDir); + + expect(second).toEqual([]); + expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2"); + }); +}); diff --git a/packages/db/src/embedded-postgres-native.ts b/packages/db/src/embedded-postgres-native.ts new file mode 100644 index 00000000..a304f8a1 --- /dev/null +++ b/packages/db/src/embedded-postgres-native.ts @@ -0,0 +1,85 @@ +import { promises as fs } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const require = createRequire(import.meta.url); + +function resolveNativePackageName(): string | null { + if (process.platform !== "linux") return null; + + switch (process.arch) { + case "arm64": + return "linux-arm64"; + case "arm": + return "linux-arm"; + case "ia32": + return "linux-ia32"; + case "ppc64": + return "linux-ppc64"; + case "x64": + return "linux-x64"; + default: + return null; + } +} + +async function pathExists(value: string): Promise { + try { + await fs.stat(value); + return true; + } catch { + return false; + } +} + +function resolveEmbeddedPostgresPackageRoot(): string | null { + try { + const entry = require.resolve("embedded-postgres"); + return path.dirname(path.dirname(entry)); + } catch { + return null; + } +} + +function prependPathEnv(name: string, value: string): void { + const current = process.env[name] ?? ""; + const parts = current.split(path.delimiter).filter(Boolean); + if (parts.includes(value)) return; + process.env[name] = [value, ...parts].join(path.delimiter); +} + +export async function ensureLinuxSharedLibraryAliases(libDir: string): Promise { + const entries = await fs.readdir(libDir, { withFileTypes: true }); + const created: string[] = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + const match = entry.name.match(/^(lib.+\.so\.\d+)\.\d+(?:\.\d+)?$/); + if (!match) continue; + + const aliasName = match[1]; + const aliasPath = path.join(libDir, aliasName); + try { + await fs.symlink(entry.name, aliasPath); + created.push(aliasPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; + throw error; + } + } + + return created; +} + +export async function prepareEmbeddedPostgresNativeRuntime(): Promise { + const nativePackageName = resolveNativePackageName(); + const packageRoot = resolveEmbeddedPostgresPackageRoot(); + if (!nativePackageName || !packageRoot) return; + + const nativeRoot = path.resolve(packageRoot, "..", "@embedded-postgres", nativePackageName); + const libDir = path.join(nativeRoot, "native", "lib"); + if (!(await pathExists(libDir))) return; + + prependPathEnv("LD_LIBRARY_PATH", libDir); + await ensureLinuxSharedLibraryAliases(libDir); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3bf4d22b..a1c5bce8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -30,6 +30,10 @@ export { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError, } from "./embedded-postgres-error.js"; +export { + ensureLinuxSharedLibraryAliases, + prepareEmbeddedPostgresNativeRuntime, +} from "./embedded-postgres-native.js"; export { issueRelations } from "./schema/issue_relations.js"; export { issueReferenceMentions } from "./schema/issue_reference_mentions.js"; export * from "./schema/index.js"; diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts index 5aa2b6a2..3823b6d0 100644 --- a/packages/db/src/migration-runtime.ts +++ b/packages/db/src/migration-runtime.ts @@ -3,6 +3,7 @@ import { createServer } from "node:net"; import path from "node:path"; import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js"; import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js"; +import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js"; import { resolveDatabaseTarget } from "./runtime-config.js"; type EmbeddedPostgresInstance = { @@ -92,6 +93,7 @@ async function ensureEmbeddedPostgresConnection( preferredPort: number, ): Promise { const EmbeddedPostgres = await loadEmbeddedPostgresCtor(); + await prepareEmbeddedPostgresNativeRuntime(); const selectedPort = await findAvailablePort(preferredPort); const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const pgVersionFile = path.resolve(dataDir, "PG_VERSION"); diff --git a/packages/db/src/migrations/0085_tranquil_the_executioner.sql b/packages/db/src/migrations/0085_tranquil_the_executioner.sql new file mode 100644 index 00000000..5dc6e3cb --- /dev/null +++ b/packages/db/src/migrations/0085_tranquil_the_executioner.sql @@ -0,0 +1,8 @@ +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_user_id" text;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'documents_locked_by_agent_id_agents_id_fk') THEN + ALTER TABLE "documents" ADD CONSTRAINT "documents_locked_by_agent_id_agents_id_fk" FOREIGN KEY ("locked_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; diff --git a/packages/db/src/migrations/0086_routine_env_runtime_contract.sql b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql new file mode 100644 index 00000000..55228ccb --- /dev/null +++ b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql @@ -0,0 +1,8 @@ +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "env" jsonb;--> statement-breakpoint +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "routine_revision_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routine_runs_routine_revision_id_routine_revisions_id_fk') THEN + ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_revision_id_routine_revisions_id_fk" FOREIGN KEY ("routine_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_revision_idx" ON "routine_runs" USING btree ("routine_revision_id"); diff --git a/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql new file mode 100644 index 00000000..4d05e171 --- /dev/null +++ b/packages/db/src/migrations/0087_backfill_environment_manage_human_defaults.sql @@ -0,0 +1,29 @@ +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'user', + "principal_id", + 'environments:manage', + NULL, + NULL, + now(), + now() +FROM "company_memberships" +WHERE "principal_type" = 'user' + AND "status" = 'active' + AND "membership_role" IN ('owner', 'admin') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql new file mode 100644 index 00000000..6e78c7e5 --- /dev/null +++ b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql @@ -0,0 +1,75 @@ +INSERT INTO "company_memberships" ( + "company_id", + "principal_type", + "principal_id", + "status", + "membership_role", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'agent', + "id", + 'active', + 'member', + now(), + now() +FROM "agents" +WHERE "status" NOT IN ('pending_approval', 'terminated') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id" +) DO NOTHING; + +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + memberships."company_id", + 'user', + memberships."principal_id", + role_defaults."permission_key", + NULL, + NULL, + now(), + now() +FROM "company_memberships" memberships +JOIN ( + VALUES + ('owner', 'agents:create'), + ('owner', 'environments:manage'), + ('owner', 'users:invite'), + ('owner', 'users:manage_permissions'), + ('owner', 'tasks:assign'), + ('owner', 'joins:approve'), + ('admin', 'agents:create'), + ('admin', 'environments:manage'), + ('admin', 'users:invite'), + ('admin', 'tasks:assign'), + ('admin', 'joins:approve'), + ('operator', 'tasks:assign') +) AS role_defaults("membership_role", "permission_key") + ON role_defaults."membership_role" = CASE + WHEN memberships."membership_role" = 'owner' THEN 'owner' + WHEN memberships."membership_role" = 'admin' THEN 'admin' + WHEN memberships."membership_role" = 'viewer' THEN 'viewer' + WHEN memberships."membership_role" = 'member' THEN 'operator' + ELSE 'operator' + END +WHERE memberships."principal_type" = 'user' + AND memberships."status" = 'active' +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/0089_cloud_upstreams.sql b/packages/db/src/migrations/0089_cloud_upstreams.sql new file mode 100644 index 00000000..caf14522 --- /dev/null +++ b/packages/db/src/migrations/0089_cloud_upstreams.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "remote_url" text NOT NULL, + "source_instance_id" text NOT NULL, + "source_instance_fingerprint" text NOT NULL, + "source_public_key" text NOT NULL, + "private_key_pem" text NOT NULL, + "token_status" text NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "authorized_global_user_id" text, + "access_token" text, + "token_id" text, + "token_expires_at" timestamp with time zone, + "target_stack_id" text NOT NULL, + "target_stack_slug" text, + "target_stack_display_name" text, + "target_company_id" text NOT NULL, + "target_origin" text NOT NULL, + "target_primary_host" text NOT NULL, + "target_product" text NOT NULL, + "target_schema_major" integer NOT NULL, + "target_max_chunk_bytes" integer NOT NULL, + "pending_state" text, + "pending_code_verifier" text, + "pending_redirect_uri" text, + "pending_token_url" text, + "last_run_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "connection_id" uuid NOT NULL, + "company_id" uuid NOT NULL, + "remote_run_id" text, + "status" text NOT NULL, + "active_step" text NOT NULL, + "progress_percent" integer DEFAULT 0 NOT NULL, + "dry_run" boolean DEFAULT false NOT NULL, + "retry_of_run_id" uuid, + "summary" jsonb DEFAULT '[]'::jsonb NOT NULL, + "warnings" jsonb DEFAULT '[]'::jsonb NOT NULL, + "conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "events" jsonb DEFAULT '[]'::jsonb NOT NULL, + "report" jsonb DEFAULT '{}'::jsonb NOT NULL, + "idempotency_key" text NOT NULL, + "manifest_hash" text NOT NULL, + "target_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone +);--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN + ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN + ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN + ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id"); diff --git a/packages/db/src/migrations/0090_resource_memberships.sql b/packages/db/src/migrations/0090_resource_memberships.sql new file mode 100644 index 00000000..fd137662 --- /dev/null +++ b/packages/db/src/migrations/0090_resource_memberships.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS "agent_memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "agent_id" uuid NOT NULL, + "user_id" text NOT NULL, + "state" text DEFAULT 'joined' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "project_memberships" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "user_id" text NOT NULL, + "state" text DEFAULT 'joined' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_company_id_companies_id_fk') THEN + ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_agent_id_agents_id_fk') THEN + ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_company_id_companies_id_fk') THEN + ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_project_id_projects_id_fk') THEN + ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_idx" ON "agent_memberships" USING btree ("company_id","user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "agent_memberships_agent_idx" ON "agent_memberships" USING btree ("agent_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "agent_memberships_company_user_agent_uq" ON "agent_memberships" USING btree ("company_id","user_id","agent_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "project_memberships_company_user_idx" ON "project_memberships" USING btree ("company_id","user_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "project_memberships_project_idx" ON "project_memberships" USING btree ("project_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "project_memberships_company_user_project_uq" ON "project_memberships" USING btree ("company_id","user_id","project_id"); diff --git a/packages/db/src/migrations/meta/0090_snapshot.json b/packages/db/src/migrations/meta/0090_snapshot.json new file mode 100644 index 00000000..b6a2225d --- /dev/null +++ b/packages/db/src/migrations/meta/0090_snapshot.json @@ -0,0 +1,17974 @@ +{ + "id": "11e30d07-51bf-4073-badc-f65fd3de13ad", + "prevId": "a7ba5d6c-9f74-487d-a9c1-56a4d5455b92", + "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_memberships": { + "name": "agent_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 + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'joined'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time 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_memberships_company_user_idx": { + "name": "agent_memberships_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": {} + }, + "agent_memberships_agent_idx": { + "name": "agent_memberships_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memberships_company_user_agent_uq": { + "name": "agent_memberships_company_user_agent_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memberships_company_id_companies_id_fk": { + "name": "agent_memberships_company_id_companies_id_fk", + "tableFrom": "agent_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memberships_agent_id_agents_id_fk": { + "name": "agent_memberships_agent_id_agents_id_fk", + "tableFrom": "agent_memberships", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "default_environment_id": { + "name": "default_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_default_environment_idx": { + "name": "agents_company_default_environment_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "default_environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_default_environment_id_environments_id_fk": { + "name": "agents_default_environment_id_environments_id_fk", + "tableFrom": "agents", + "tableTo": "environments", + "columnsFrom": [ + "default_environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.board_api_keys": { + "name": "board_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "board_api_keys_key_hash_idx": { + "name": "board_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_api_keys_user_idx": { + "name": "board_api_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_api_keys_user_id_user_id_fk": { + "name": "board_api_keys_user_id_user_id_fk", + "tableFrom": "board_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_incidents": { + "name": "budget_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "threshold_type": { + "name": "threshold_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_limit": { + "name": "amount_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_observed": { + "name": "amount_observed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_incidents_company_status_idx": { + "name": "budget_incidents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_company_scope_idx": { + "name": "budget_incidents_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_policy_window_threshold_idx": { + "name": "budget_incidents_policy_window_threshold_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "threshold_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_incidents_company_id_companies_id_fk": { + "name": "budget_incidents_company_id_companies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_policy_id_budget_policies_id_fk": { + "name": "budget_incidents_policy_id_budget_policies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "budget_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_approval_id_approvals_id_fk": { + "name": "budget_incidents_approval_id_approvals_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_policies": { + "name": "budget_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'billed_cents'" + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_percent": { + "name": "warn_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "hard_stop_enabled": { + "name": "hard_stop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_enabled": { + "name": "notify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_policies_company_scope_active_idx": { + "name": "budget_policies_company_scope_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_window_idx": { + "name": "budget_policies_company_window_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_scope_metric_unique_idx": { + "name": "budget_policies_company_scope_metric_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_policies_company_id_companies_id_fk": { + "name": "budget_policies_company_id_companies_id_fk", + "tableFrom": "budget_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_auth_challenges": { + "name": "cli_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_access": { + "name": "requested_access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'board'" + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_key_hash": { + "name": "pending_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pending_key_name": { + "name": "pending_key_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_api_key_id": { + "name": "board_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cli_auth_challenges_secret_hash_idx": { + "name": "cli_auth_challenges_secret_hash_idx", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_approved_by_idx": { + "name": "cli_auth_challenges_approved_by_idx", + "columns": [ + { + "expression": "approved_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_requested_company_idx": { + "name": "cli_auth_challenges_requested_company_idx", + "columns": [ + { + "expression": "requested_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_auth_challenges_requested_company_id_companies_id_fk": { + "name": "cli_auth_challenges_requested_company_id_companies_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "companies", + "columnsFrom": [ + "requested_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_approved_by_user_id_user_id_fk": { + "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { + "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "board_api_keys", + "columnsFrom": [ + "board_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attachment_max_bytes": { + "name": "attachment_max_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10485760 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_enabled": { + "name": "feedback_data_sharing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_consent_at": { + "name": "feedback_data_sharing_consent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_consent_by_user_id": { + "name": "feedback_data_sharing_consent_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_terms_version": { + "name": "feedback_data_sharing_terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_logos": { + "name": "company_logos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_bindings": { + "name": "company_secret_bindings", + "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 + }, + "secret_id": { + "name": "secret_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 + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_selector": { + "name": "version_selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'latest'" + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "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_secret_bindings_company_idx": { + "name": "company_secret_bindings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_secret_idx": { + "name": "company_secret_bindings_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_target_idx": { + "name": "company_secret_bindings_target_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" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_target_path_uq": { + "name": "company_secret_bindings_target_path_uq", + "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": "config_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_bindings_company_id_companies_id_fk": { + "name": "company_secret_bindings_company_id_companies_id_fk", + "tableFrom": "company_secret_bindings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secret_bindings_secret_id_company_secrets_id_fk": { + "name": "company_secret_bindings_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_bindings", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_provider_configs": { + "name": "company_secret_provider_configs", + "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 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_checked_at": { + "name": "health_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "health_message": { + "name": "health_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_details": { + "name": "health_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time 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_secret_provider_configs_company_idx": { + "name": "company_secret_provider_configs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_provider_configs_company_provider_idx": { + "name": "company_secret_provider_configs_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_secret_provider_configs_default_uq": { + "name": "company_secret_provider_configs_default_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_secret_provider_configs\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_provider_configs_company_id_companies_id_fk": { + "name": "company_secret_provider_configs_company_id_companies_id_fk", + "tableFrom": "company_secret_provider_configs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_provider_configs_created_by_agent_id_agents_id_fk": { + "name": "company_secret_provider_configs_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_provider_configs", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "provider_version_ref": { + "name": "provider_version_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'current'" + }, + "fingerprint_sha256": { + "name": "fingerprint_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation_job_id": { + "name": "rotation_job_id", + "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()" + }, + "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_fingerprint_idx": { + "name": "company_secret_versions_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint_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 + }, + "key": { + "name": "key", + "type": "text", + "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'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "managed_mode": { + "name": "managed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip_managed'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config_id": { + "name": "provider_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "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 + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_rotated_at": { + "name": "last_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time 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_provider_config_idx": { + "name": "company_secrets_provider_config_idx", + "columns": [ + { + "expression": "provider_config_id", + "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": {} + }, + "company_secrets_company_key_uq": { + "name": "company_secrets_company_key_uq", + "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": {} + } + }, + "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_provider_config_id_company_secret_provider_configs_id_fk": { + "name": "company_secrets_provider_config_id_company_secret_provider_configs_id_fk", + "tableFrom": "company_secrets", + "tableTo": "company_secret_provider_configs", + "columnsFrom": [ + "provider_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skills_company_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "document_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by_agent_id": { + "name": "locked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "locked_by_user_id": { + "name": "locked_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": {} + }, + "documents_title_search_idx": { + "name": "documents_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "documents_latest_body_search_idx": { + "name": "documents_latest_body_search_idx", + "columns": [ + { + "expression": "latest_body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "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" + }, + "documents_locked_by_agent_id_agents_id_fk": { + "name": "documents_locked_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "locked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_leases": { + "name": "environment_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "lease_policy": { + "name": "lease_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ephemeral'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_status": { + "name": "cleanup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_leases_company_environment_status_idx": { + "name": "environment_leases_company_environment_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_execution_workspace_idx": { + "name": "environment_leases_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_issue_idx": { + "name": "environment_leases_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_heartbeat_run_idx": { + "name": "environment_leases_heartbeat_run_idx", + "columns": [ + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_last_used_idx": { + "name": "environment_leases_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_provider_lease_idx": { + "name": "environment_leases_provider_lease_idx", + "columns": [ + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_leases_company_id_companies_id_fk": { + "name": "environment_leases_company_id_companies_id_fk", + "tableFrom": "environment_leases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_leases_environment_id_environments_id_fk": { + "name": "environment_leases_environment_id_environments_id_fk", + "tableFrom": "environment_leases", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_leases_execution_workspace_id_execution_workspaces_id_fk": { + "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "environment_leases", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_issue_id_issues_id_fk": { + "name": "environment_leases_issue_id_issues_id_fk", + "tableFrom": "environment_leases", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "environment_leases", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driver": { + "name": "driver", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_company_status_idx": { + "name": "environments_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_company_driver_idx": { + "name": "environments_company_driver_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environments\".\"driver\" = 'local'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_company_name_idx": { + "name": "environments_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_company_id_companies_id_fk": { + "name": "environments_company_id_companies_id_fk", + "tableFrom": "environments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_exports": { + "name": "feedback_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_vote_id": { + "name": "feedback_vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_only'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "export_id": { + "name": "export_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-envelope-v2'" + }, + "bundle_version": { + "name": "bundle_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-bundle-v2'" + }, + "payload_version": { + "name": "payload_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-v1'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_snapshot": { + "name": "payload_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_summary": { + "name": "target_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_exports_feedback_vote_idx": { + "name": "feedback_exports_feedback_vote_idx", + "columns": [ + { + "expression": "feedback_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_created_idx": { + "name": "feedback_exports_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_status_idx": { + "name": "feedback_exports_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_issue_idx": { + "name": "feedback_exports_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_project_idx": { + "name": "feedback_exports_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_author_idx": { + "name": "feedback_exports_company_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_exports_company_id_companies_id_fk": { + "name": "feedback_exports_company_id_companies_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { + "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", + "columnsFrom": [ + "feedback_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_issue_id_issues_id_fk": { + "name": "feedback_exports_issue_id_issues_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_project_id_projects_id_fk": { + "name": "feedback_exports_project_id_projects_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_votes": { + "name": "feedback_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_with_labs": { + "name": "shared_with_labs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_votes_company_issue_idx": { + "name": "feedback_votes_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_issue_target_idx": { + "name": "feedback_votes_issue_target_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_author_idx": { + "name": "feedback_votes_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_company_target_author_idx": { + "name": "feedback_votes_company_target_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_votes_company_id_companies_id_fk": { + "name": "feedback_votes_company_id_companies_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_votes_issue_id_issues_id_fk": { + "name": "feedback_votes_issue_id_issues_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "finance_events_company_occurred_idx": { + "name": "finance_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_watchdog_decisions": { + "name": "heartbeat_run_watchdog_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "evaluation_issue_id": { + "name": "evaluation_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_watchdog_decisions_company_run_created_idx": { + "name": "heartbeat_run_watchdog_decisions_company_run_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_watchdog_decisions_company_run_snooze_idx": { + "name": "heartbeat_run_watchdog_decisions_company_run_snooze_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snoozed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_watchdog_decisions_company_id_companies_id_fk": { + "name": "heartbeat_run_watchdog_decisions_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk": { + "name": "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "issues", + "columnsFrom": [ + "evaluation_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk": { + "name": "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_group_id": { + "name": "process_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_output_at": { + "name": "last_output_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_output_seq": { + "name": "last_output_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_output_stream": { + "name": "last_output_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_output_bytes": { + "name": "last_output_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_retry_at": { + "name": "scheduled_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_retry_attempt": { + "name": "scheduled_retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_retry_reason": { + "name": "scheduled_retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_comment_status": { + "name": "issue_comment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "issue_comment_satisfied_by_comment_id": { + "name": "issue_comment_satisfied_by_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_comment_retry_queued_at": { + "name": "issue_comment_retry_queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_status_last_output_idx": { + "name": "heartbeat_runs_company_status_last_output_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_status_process_started_idx": { + "name": "heartbeat_runs_company_status_process_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "process_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inbox_dismissals": { + "name": "inbox_dismissals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_dismissals_company_user_idx": { + "name": "inbox_dismissals_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_item_idx": { + "name": "inbox_dismissals_company_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_user_item_idx": { + "name": "inbox_dismissals_company_user_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_dismissals_company_id_companies_id_fk": { + "name": "inbox_dismissals_company_id_companies_id_fk", + "tableFrom": "inbox_dismissals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_type": { + "name": "author_type", + "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 + }, + "presentation": { + "name": "presentation", + "type": "jsonb", + "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": { + "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_recovery_actions": { + "name": "issue_recovery_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recovery_issue_id": { + "name": "recovery_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_owner_agent_id": { + "name": "previous_owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "return_owner_agent_id": { + "name": "return_owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cause": { + "name": "cause", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wake_policy": { + "name": "wake_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "monitor_policy": { + "name": "monitor_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_recovery_actions_company_source_status_idx": { + "name": "issue_recovery_actions_company_source_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_company_owner_status_idx": { + "name": "issue_recovery_actions_company_owner_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_company_recovery_issue_idx": { + "name": "issue_recovery_actions_company_recovery_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recovery_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_active_source_uq": { + "name": "issue_recovery_actions_active_source_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_active_fingerprint_uq": { + "name": "issue_recovery_actions_active_fingerprint_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cause", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_recovery_actions_company_id_companies_id_fk": { + "name": "issue_recovery_actions_company_id_companies_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_recovery_actions_source_issue_id_issues_id_fk": { + "name": "issue_recovery_actions_source_issue_id_issues_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_recovery_actions_recovery_issue_id_issues_id_fk": { + "name": "issue_recovery_actions_recovery_issue_id_issues_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "issues", + "columnsFrom": [ + "recovery_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_previous_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_previous_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "previous_owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_return_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_return_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "return_owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_reference_mentions": { + "name": "issue_reference_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_issue_id": { + "name": "target_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_record_id": { + "name": "source_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_text": { + "name": "matched_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_reference_mentions_company_source_issue_idx": { + "name": "issue_reference_mentions_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_target_issue_idx": { + "name": "issue_reference_mentions_company_target_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_issue_pair_idx": { + "name": "issue_reference_mentions_company_issue_pair_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_record_uq": { + "name": "issue_reference_mentions_company_source_mention_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_null_record_uq": { + "name": "issue_reference_mentions_company_source_mention_null_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_reference_mentions_company_id_companies_id_fk": { + "name": "issue_reference_mentions_company_id_companies_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_reference_mentions_source_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_source_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_reference_mentions_target_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_target_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "target_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_relations": { + "name": "issue_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "related_issue_id": { + "name": "related_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { + "name": "issue_relations_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_related_issue_idx": { + "name": "issue_relations_company_related_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_type_idx": { + "name": "issue_relations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_edge_uq": { + "name": "issue_relations_company_edge_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_relations_company_id_companies_id_fk": { + "name": "issue_relations_company_id_companies_id_fk", + "tableFrom": "issue_relations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_relations_issue_id_issues_id_fk": { + "name": "issue_relations_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_related_issue_id_issues_id_fk": { + "name": "issue_relations_related_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "related_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_created_by_agent_id_agents_id_fk": { + "name": "issue_relations_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_relations", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_thread_interactions": { + "name": "issue_thread_interactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "continuation_policy": { + "name": "continuation_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'wake_assignee'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_run_id": { + "name": "source_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by_agent_id": { + "name": "resolved_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_thread_interactions_issue_idx": { + "name": "issue_thread_interactions_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_company_issue_created_at_idx": { + "name": "issue_thread_interactions_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_company_issue_status_idx": { + "name": "issue_thread_interactions_company_issue_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_company_issue_idempotency_uq": { + "name": "issue_thread_interactions_company_issue_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_source_comment_idx": { + "name": "issue_thread_interactions_source_comment_idx", + "columns": [ + { + "expression": "source_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_thread_interactions_company_id_companies_id_fk": { + "name": "issue_thread_interactions_company_id_companies_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_issue_id_issues_id_fk": { + "name": "issue_thread_interactions_issue_id_issues_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_comment_id_issue_comments_id_fk": { + "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issue_comments", + "columnsFrom": [ + "source_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": { + "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "source_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_created_by_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_resolved_by_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_resolved_by_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "resolved_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_tree_hold_members": { + "name": "issue_tree_hold_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hold_id": { + "name": "hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_issue_id": { + "name": "parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issue_identifier": { + "name": "issue_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_status": { + "name": "issue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_run_status": { + "name": "active_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_tree_hold_members_hold_issue_uq": { + "name": "issue_tree_hold_members_hold_issue_uq", + "columns": [ + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_tree_hold_members_company_issue_idx": { + "name": "issue_tree_hold_members_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_tree_hold_members_hold_depth_idx": { + "name": "issue_tree_hold_members_hold_depth_idx", + "columns": [ + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depth", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_tree_hold_members_company_id_companies_id_fk": { + "name": "issue_tree_hold_members_company_id_companies_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk": { + "name": "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issue_tree_holds", + "columnsFrom": [ + "hold_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_hold_members_issue_id_issues_id_fk": { + "name": "issue_tree_hold_members_issue_id_issues_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_hold_members_parent_issue_id_issues_id_fk": { + "name": "issue_tree_hold_members_parent_issue_id_issues_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_hold_members_assignee_agent_id_agents_id_fk": { + "name": "issue_tree_hold_members_assignee_agent_id_agents_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "active_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_tree_holds": { + "name": "issue_tree_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "root_issue_id": { + "name": "root_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_policy": { + "name": "release_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_by_actor_type": { + "name": "released_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "released_by_agent_id": { + "name": "released_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_by_user_id": { + "name": "released_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "released_by_run_id": { + "name": "released_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_metadata": { + "name": "release_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_tree_holds_company_root_status_idx": { + "name": "issue_tree_holds_company_root_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "root_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_tree_holds_company_status_mode_idx": { + "name": "issue_tree_holds_company_status_mode_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_tree_holds_company_id_companies_id_fk": { + "name": "issue_tree_holds_company_id_companies_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_tree_holds_root_issue_id_issues_id_fk": { + "name": "issue_tree_holds_root_issue_id_issues_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "issues", + "columnsFrom": [ + "root_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_holds_created_by_agent_id_agents_id_fk": { + "name": "issue_tree_holds_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_released_by_agent_id_agents_id_fk": { + "name": "issue_tree_holds_released_by_agent_id_agents_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "agents", + "columnsFrom": [ + "released_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "released_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_work_products": { + "name": "issue_work_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_work_products_company_issue_type_idx": { + "name": "issue_work_products_company_issue_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_execution_workspace_type_idx": { + "name": "issue_work_products_company_execution_workspace_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_provider_external_id_idx": { + "name": "issue_work_products_company_provider_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_updated_idx": { + "name": "issue_work_products_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_work_products_company_id_companies_id_fk": { + "name": "issue_work_products_company_id_companies_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_work_products_project_id_projects_id_fk": { + "name": "issue_work_products_project_id_projects_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_issue_id_issues_id_fk": { + "name": "issue_work_products_issue_id_issues_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { + "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", + "columnsFrom": [ + "runtime_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "work_mode": { + "name": "work_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_fingerprint": { + "name": "origin_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_policy": { + "name": "execution_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_state": { + "name": "execution_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "monitor_next_check_at": { + "name": "monitor_next_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_wake_requested_at": { + "name": "monitor_wake_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_last_triggered_at": { + "name": "monitor_last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_attempt_count": { + "name": "monitor_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monitor_notes": { + "name": "monitor_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monitor_scheduled_by": { + "name": "monitor_scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_preference": { + "name": "execution_workspace_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_origin_idx": { + "name": "issues_company_origin_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_workspace_idx": { + "name": "issues_company_project_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_execution_workspace_idx": { + "name": "issues_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_monitor_due_idx": { + "name": "issues_company_monitor_due_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "monitor_next_check_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_title_search_idx": { + "name": "issues_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_identifier_search_idx": { + "name": "issues_identifier_search_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_description_search_idx": { + "name": "issues_description_search_idx", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_open_routine_execution_uq": { + "name": "issues_open_routine_execution_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_liveness_recovery_incident_uq": { + "name": "issues_active_liveness_recovery_incident_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_liveness_recovery_leaf_uq": { + "name": "issues_active_liveness_recovery_leaf_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_stale_run_evaluation_uq": { + "name": "issues_active_stale_run_evaluation_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_productivity_review_uq": { + "name": "issues_active_productivity_review_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_stranded_issue_recovery_uq": { + "name": "issues_active_stranded_issue_recovery_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_company_settings": { + "name": "plugin_company_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_config_plugin_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_database_namespaces": { + "name": "plugin_database_namespaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_mode": { + "name": "namespace_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schema'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_database_namespaces_plugin_idx": { + "name": "plugin_database_namespaces_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_namespace_idx": { + "name": "plugin_database_namespaces_namespace_idx", + "columns": [ + { + "expression": "namespace_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_status_idx": { + "name": "plugin_database_namespaces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_database_namespaces_plugin_id_plugins_id_fk": { + "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", + "tableFrom": "plugin_database_namespaces", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_managed_resources": { + "name": "plugin_managed_resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "defaults_json": { + "name": "defaults_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_managed_resources_company_idx": { + "name": "plugin_managed_resources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_plugin_idx": { + "name": "plugin_managed_resources_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_resource_idx": { + "name": "plugin_managed_resources_resource_idx", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_company_plugin_resource_uq": { + "name": "plugin_managed_resources_company_plugin_resource_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_managed_resources_company_id_companies_id_fk": { + "name": "plugin_managed_resources_company_id_companies_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_managed_resources_plugin_id_plugins_id_fk": { + "name": "plugin_managed_resources_plugin_id_plugins_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_key": { + "name": "migration_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "plugin_migrations_plugin_key_idx": { + "name": "plugin_migrations_plugin_key_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_plugin_idx": { + "name": "plugin_migrations_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_status_idx": { + "name": "plugin_migrations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_memberships": { + "name": "project_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 + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'joined'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time 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_memberships_company_user_idx": { + "name": "project_memberships_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": {} + }, + "project_memberships_project_idx": { + "name": "project_memberships_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_memberships_company_user_project_uq": { + "name": "project_memberships_company_user_project_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_memberships_company_id_companies_id_fk": { + "name": "project_memberships_company_id_companies_id_fk", + "tableFrom": "project_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_memberships_project_id_projects_id_fk": { + "name": "project_memberships_project_id_projects_id_fk", + "tableFrom": "project_memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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_revisions": { + "name": "routine_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 + }, + "routine_id": { + "name": "routine_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": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "restored_from_revision_id": { + "name": "restored_from_revision_id", + "type": "uuid", + "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": { + "routine_revisions_routine_revision_uq": { + "name": "routine_revisions_routine_revision_uq", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_revisions_company_routine_created_idx": { + "name": "routine_revisions_company_routine_created_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": {} + } + }, + "foreignKeys": { + "routine_revisions_company_id_companies_id_fk": { + "name": "routine_revisions_company_id_companies_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_routine_id_routines_id_fk": { + "name": "routine_revisions_routine_id_routines_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": { + "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routine_revisions", + "columnsFrom": [ + "restored_from_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_agent_id_agents_id_fk": { + "name": "routine_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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()" + }, + "routine_revision_id": { + "name": "routine_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_payload": { + "name": "trigger_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatch_fingerprint": { + "name": "dispatch_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_issue_id": { + "name": "linked_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "coalesced_into_run_id": { + "name": "coalesced_into_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_runs_company_routine_idx": { + "name": "routine_runs_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_revision_idx": { + "name": "routine_runs_revision_idx", + "columns": [ + { + "expression": "routine_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idx": { + "name": "routine_runs_trigger_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_dispatch_fingerprint_idx": { + "name": "routine_runs_dispatch_fingerprint_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatch_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_linked_issue_idx": { + "name": "routine_runs_linked_issue_idx", + "columns": [ + { + "expression": "linked_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idempotency_idx": { + "name": "routine_runs_trigger_idempotency_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_company_id_companies_id_fk": { + "name": "routine_runs_company_id_companies_id_fk", + "tableFrom": "routine_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_trigger_id_routine_triggers_id_fk": { + "name": "routine_runs_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_routine_revision_id_routine_revisions_id_fk": { + "name": "routine_runs_routine_revision_id_routine_revisions_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_revisions", + "columnsFrom": [ + "routine_revision_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" + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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.secret_access_events": { + "name": "secret_access_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 + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumer_type": { + "name": "consumer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer_id": { + "name": "consumer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_access_events_company_created_idx": { + "name": "secret_access_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": {} + }, + "secret_access_events_secret_created_idx": { + "name": "secret_access_events_secret_created_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": {} + }, + "secret_access_events_consumer_idx": { + "name": "secret_access_events_consumer_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_access_events_run_idx": { + "name": "secret_access_events_run_idx", + "columns": [ + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_access_events_company_id_companies_id_fk": { + "name": "secret_access_events_company_id_companies_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "secret_access_events_secret_id_company_secrets_id_fk": { + "name": "secret_access_events_secret_id_company_secrets_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secret_access_events_issue_id_issues_id_fk": { + "name": "secret_access_events_issue_id_issues_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "secret_access_events_plugin_id_plugins_id_fk": { + "name": "secret_access_events_plugin_id_plugins_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operations": { + "name": "workspace_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operations_company_run_started_idx": { + "name": "workspace_operations_company_run_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_started_idx": { + "name": "workspace_operations_company_workspace_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operations_company_id_companies_id_fk": { + "name": "workspace_operations_company_id_companies_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "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 9d124c7d..a4509f32 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -596,6 +596,48 @@ "when": 1778355326070, "tag": "0084_issue_recovery_actions", "breakpoints": true + }, + { + "idx": 85, + "version": "7", + "when": 1778787362162, + "tag": "0085_tranquil_the_executioner", + "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1778976000000, + "tag": "0086_routine_env_runtime_contract", + "breakpoints": true + }, + { + "idx": 87, + "version": "7", + "when": 1779360000000, + "tag": "0087_backfill_environment_manage_human_defaults", + "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1779446400000, + "tag": "0088_backfill_principal_access_compatibility", + "breakpoints": true + }, + { + "idx": 89, + "version": "7", + "when": 1779129600000, + "tag": "0089_cloud_upstreams", + "breakpoints": true + }, + { + "idx": 90, + "version": "7", + "when": 1779573019125, + "tag": "0090_resource_memberships", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agent_memberships.ts b/packages/db/src/schema/agent_memberships.ts new file mode 100644 index 00000000..8ff4a87a --- /dev/null +++ b/packages/db/src/schema/agent_memberships.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; + +export const agentMemberships = pgTable( + "agent_memberships", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + state: text("state").notNull().default("joined"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId), + agentIdx: index("agent_memberships_agent_idx").on(table.agentId), + companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on( + table.companyId, + table.userId, + table.agentId, + ), + }), +); diff --git a/packages/db/src/schema/cloud_upstreams.ts b/packages/db/src/schema/cloud_upstreams.ts new file mode 100644 index 00000000..93b4341f --- /dev/null +++ b/packages/db/src/schema/cloud_upstreams.ts @@ -0,0 +1,75 @@ +import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +export const cloudUpstreamConnections = pgTable( + "cloud_upstream_connections", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + remoteUrl: text("remote_url").notNull(), + sourceInstanceId: text("source_instance_id").notNull(), + sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(), + sourcePublicKey: text("source_public_key").notNull(), + // Stored through the Cloud Upstream service as an encrypted credential envelope. + privateKeyPem: text("private_key_pem").notNull(), + tokenStatus: text("token_status").notNull(), + scopes: text("scopes").array().notNull().default([]), + authorizedGlobalUserId: text("authorized_global_user_id"), + // Stored through the Cloud Upstream service as an encrypted credential envelope. + accessToken: text("access_token"), + tokenId: text("token_id"), + tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }), + + targetStackId: text("target_stack_id").notNull(), + targetStackSlug: text("target_stack_slug"), + targetStackDisplayName: text("target_stack_display_name"), + targetCompanyId: text("target_company_id").notNull(), + targetOrigin: text("target_origin").notNull(), + targetPrimaryHost: text("target_primary_host").notNull(), + targetProduct: text("target_product").notNull(), + targetSchemaMajor: integer("target_schema_major").notNull(), + targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(), + + pendingState: text("pending_state"), + pendingCodeVerifier: text("pending_code_verifier"), + pendingRedirectUri: text("pending_redirect_uri"), + pendingTokenUrl: text("pending_token_url"), + + lastRunId: uuid("last_run_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("cloud_upstream_connections_company_idx").on(table.companyId), + ], +); + +export const cloudUpstreamRuns = pgTable( + "cloud_upstream_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + remoteRunId: text("remote_run_id"), + status: text("status").notNull(), + activeStep: text("active_step").notNull(), + progressPercent: integer("progress_percent").notNull().default(0), + dryRun: boolean("dry_run").notNull().default(false), + retryOfRunId: uuid("retry_of_run_id"), + summary: jsonb("summary").$type().notNull().default([]), + warnings: jsonb("warnings").$type().notNull().default([]), + conflicts: jsonb("conflicts").$type().notNull().default([]), + events: jsonb("events").$type().notNull().default([]), + report: jsonb("report").$type>().notNull().default({}), + idempotencyKey: text("idempotency_key").notNull(), + manifestHash: text("manifest_hash").notNull(), + targetUrl: text("target_url"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }), + }, + (table) => [ + index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt), + index("cloud_upstream_runs_connection_idx").on(table.connectionId), + ], +); diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts index 1dae7e1d..9b56c287 100644 --- a/packages/db/src/schema/documents.ts +++ b/packages/db/src/schema/documents.ts @@ -16,6 +16,9 @@ export const documents = pgTable( createdByUserId: text("created_by_user_id"), updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), updatedByUserId: text("updated_by_user_id"), + lockedAt: timestamp("locked_at", { withTimezone: true }), + lockedByAgentId: uuid("locked_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + lockedByUserId: text("locked_by_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index fdf5f7cb..2afb0406 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,9 +2,11 @@ export { companies } from "./companies.js"; export { companyLogos } from "./company_logos.js"; export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js"; export { instanceSettings } from "./instance_settings.js"; +export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js"; export { instanceUserRoles } from "./instance_user_roles.js"; export { userSidebarPreferences } from "./user_sidebar_preferences.js"; export { agents } from "./agents.js"; +export { agentMemberships } from "./agent_memberships.js"; export { boardApiKeys } from "./board_api_keys.js"; export { cliAuthChallenges } from "./cli_auth_challenges.js"; export { companyMemberships } from "./company_memberships.js"; @@ -20,6 +22,7 @@ export { agentRuntimeState } from "./agent_runtime_state.js"; export { agentTaskSessions } from "./agent_task_sessions.js"; export { agentWakeupRequests } from "./agent_wakeup_requests.js"; export { projects } from "./projects.js"; +export { projectMemberships } from "./project_memberships.js"; export { projectWorkspaces } from "./project_workspaces.js"; export { executionWorkspaces } from "./execution_workspaces.js"; export { environments } from "./environments.js"; diff --git a/packages/db/src/schema/project_memberships.ts b/packages/db/src/schema/project_memberships.ts new file mode 100644 index 00000000..0e61294c --- /dev/null +++ b/packages/db/src/schema/project_memberships.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { projects } from "./projects.js"; + +export const projectMemberships = pgTable( + "project_memberships", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + state: text("state").notNull().default("joined"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId), + projectIdx: index("project_memberships_project_idx").on(table.projectId), + companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on( + table.companyId, + table.userId, + table.projectId, + ), + }), +); diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index dfc48143..684d12d6 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,7 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; -import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; +import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( "routines", @@ -35,6 +35,7 @@ export const routines = pgTable( concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"), catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"), variables: jsonb("variables").$type().notNull().default([]), + env: jsonb("env").$type(), latestRevisionId: uuid("latest_revision_id"), latestRevisionNumber: integer("latest_revision_number").notNull().default(1), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), @@ -131,6 +132,7 @@ export const routineRuns = pgTable( source: text("source").notNull(), status: text("status").notNull().default("received"), triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), + routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), dispatchFingerprint: text("dispatch_fingerprint"), @@ -143,6 +145,7 @@ export const routineRuns = pgTable( }, (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), + routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts index 87db5cde..929d17c5 100644 --- a/packages/db/src/test-embedded-postgres.ts +++ b/packages/db/src/test-embedded-postgres.ts @@ -3,6 +3,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js"; +import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js"; type EmbeddedPostgresInstance = { initialise(): Promise; @@ -48,6 +49,7 @@ function getReservedTestPorts(): Set { async function getEmbeddedPostgresCtor(): Promise { const mod = await import("embedded-postgres"); + await prepareEmbeddedPostgresNativeRuntime(); return mod.default as EmbeddedPostgresCtor; } diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts index 9c18f610..8a2e44c0 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts @@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink"; export const SLOT_IDS = { page: "kitchen-sink-page", settingsPage: "kitchen-sink-settings-page", + companySettingsPage: "kitchen-sink-company-settings-page", dashboardWidget: "kitchen-sink-dashboard-widget", sidebar: "kitchen-sink-sidebar-link", sidebarPanel: "kitchen-sink-sidebar-panel", @@ -23,6 +24,7 @@ export const SLOT_IDS = { export const EXPORT_NAMES = { page: "KitchenSinkPage", settingsPage: "KitchenSinkSettingsPage", + companySettingsPage: "KitchenSinkCompanySettingsPage", dashboardWidget: "KitchenSinkDashboardWidget", sidebar: "KitchenSinkSidebarLink", sidebarPanel: "KitchenSinkSidebarPanel", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index bcff32c2..5fc99281 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = { displayName: "Kitchen Sink Settings", exportName: EXPORT_NAMES.settingsPage, }, + { + type: "companySettingsPage", + id: SLOT_IDS.companySettingsPage, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.companySettingsPage, + routePath: "kitchen-sink", + }, { type: "dashboardWidget", id: SLOT_IDS.dashboardWidget, diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index ea3cb491..95ce4c14 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -10,6 +10,7 @@ import { usePluginToast, type PluginCommentAnnotationProps, type PluginCommentContextMenuItemProps, + type PluginCompanySettingsPageProps, type PluginDetailTabProps, type PluginPageProps, type PluginProjectSidebarItemProps, @@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) { ); } +export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) { + const hostNavigation = useHostNavigation(); + const overview = usePluginOverview(context.companyId); + const href = hostNavigation.resolveHref("/company/settings/kitchen-sink"); + + return ( +
+
+
+
+ Mounted inside company settings +
+ This fixture proves a ready plugin can add a settings sidebar item and render with company context. +
+ +
+
+
+
+ ); +} + export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) { const hostNavigation = useHostNavigation(); const overview = usePluginOverview(context.companyId); diff --git a/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql b/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql index e9c4a0fd..17d900a8 100644 --- a/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql +++ b/packages/plugins/plugin-llm-wiki/migrations/003_spaces.sql @@ -140,37 +140,14 @@ ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ALTER COLUMN ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ALTER COLUMN space_id SET NOT NULL; ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ALTER COLUMN space_id SET NOT NULL; -DO $$ -DECLARE - target record; - constraint_name text; -BEGIN - FOR target IN - SELECT * FROM (VALUES - ('wiki_pages', ARRAY['company_id', 'wiki_id', 'path']::text[]), - ('paperclip_distillation_cursors', ARRAY['company_id', 'wiki_id', 'source_scope', 'scope_key', 'source_kind']::text[]), - ('paperclip_distillation_work_items', ARRAY['company_id', 'wiki_id', 'idempotency_key']::text[]), - ('paperclip_page_bindings', ARRAY['company_id', 'wiki_id', 'page_path']::text[]) - ) AS targets(table_name, column_names) - LOOP - FOR constraint_name IN - SELECT c.conname - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE n.nspname = 'plugin_llm_wiki_8f50da974f' - AND t.relname = target.table_name - AND c.contype = 'u' - AND ( - SELECT array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] - FROM unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) - JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum - ) = target.column_names - LOOP - EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', 'plugin_llm_wiki_8f50da974f', target.table_name, constraint_name); - END LOOP; - END LOOP; -END $$; +ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages + DROP CONSTRAINT IF EXISTS wiki_pages_company_id_wiki_id_path_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors + DROP CONSTRAINT IF EXISTS paperclip_distillation_cursor_company_id_wiki_id_source_sco_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items + DROP CONSTRAINT IF EXISTS paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key; +ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings + DROP CONSTRAINT IF EXISTS paperclip_page_bindings_company_id_wiki_id_page_path_key; ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages DROP CONSTRAINT IF EXISTS wiki_pages_company_wiki_space_path_key; diff --git a/packages/plugins/plugin-llm-wiki/package.json b/packages/plugins/plugin-llm-wiki/package.json index 4461c845..6f175b4a 100644 --- a/packages/plugins/plugin-llm-wiki/package.json +++ b/packages/plugins/plugin-llm-wiki/package.json @@ -4,6 +4,14 @@ "type": "module", "private": true, "description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.", + "files": [ + "agents", + "dist", + "migrations", + "skills", + "templates", + "README.md" + ], "scripts": { "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", "build": "node ./esbuild.config.mjs", diff --git a/packages/plugins/plugin-llm-wiki/src/templates.ts b/packages/plugins/plugin-llm-wiki/src/templates.ts index c07bf6f6..3eb5284a 100644 --- a/packages/plugins/plugin-llm-wiki/src/templates.ts +++ b/packages/plugins/plugin-llm-wiki/src/templates.ts @@ -46,7 +46,7 @@ export const DEFAULT_AGENT_INSTRUCTIONS = DEFAULT_AGENT_INSTRUCTION_FILES["AGENT export const DEFAULT_IDEA = templateFile("IDEA.md"); export const DEFAULT_INDEX = templateFile("wiki/index.md"); export const DEFAULT_LOG = templateFile("wiki/log.md"); -export const DEFAULT_GITIGNORE = templateFile(".gitignore"); +export const DEFAULT_GITIGNORE = templateFile("gitignore.template"); export const QUERY_PROMPT = `Answer from the LLM Wiki using the installed wiki-query skill. diff --git a/packages/plugins/plugin-llm-wiki/src/ui/app.tsx b/packages/plugins/plugin-llm-wiki/src/ui/app.tsx index 8110e827..07ba8af6 100644 --- a/packages/plugins/plugin-llm-wiki/src/ui/app.tsx +++ b/packages/plugins/plugin-llm-wiki/src/ui/app.tsx @@ -155,6 +155,11 @@ type ManagedRoutine = { } | null; }; +type ManagedRoutineDefaultDrift = NonNullable; +type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & { + defaultDrift?: ManagedRoutineDefaultDrift | null; +}; + type ManagedSkill = { status: string; skillId?: string | null; @@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || ""; const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption; const currentEventPolicy = eventPolicy ?? data.eventIngestion; - const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => { + const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => { const fallback = routineFallbackFor(routine); const key = routine.resourceKey ?? routine.routineId ?? fallback.title; const status = managedRoutineStatus(routine); @@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) { if (!context.companyId || !routine.resourceKey) return; - const changedFields = routine.defaultDrift?.changedFields ?? []; + const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? []; const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults"; const confirmed = typeof window === "undefined" || window.confirm( `Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`, diff --git a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts index a969bd36..f4496fd2 100644 --- a/packages/plugins/plugin-llm-wiki/src/wiki/core.ts +++ b/packages/plugins/plugin-llm-wiki/src/wiki/core.ts @@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input return { projects, rootIssues: issues }; } -export async function updateEventIngestionSettings( - ctx: PluginContext, + export async function updateEventIngestionSettings( + ctx: PluginContext, input: { companyId: string; settings: WikiEventIngestionSettingsUpdate }, -): Promise { + ): Promise { await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId: normalizeWikiId(input.settings.wikiId), diff --git a/packages/plugins/plugin-llm-wiki/templates/.gitignore b/packages/plugins/plugin-llm-wiki/templates/gitignore.template similarity index 100% rename from packages/plugins/plugin-llm-wiki/templates/.gitignore rename to packages/plugins/plugin-llm-wiki/templates/gitignore.template diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json new file mode 100644 index 00000000..33e9a588 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -0,0 +1,72 @@ +{ + "name": "@paperclipai/plugin-workspace-diff", + "version": "0.1.0", + "description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/plugins/plugin-workspace-diff" + }, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "workspace", + "diff" + ], + "scripts": { + "postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs", + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "tsc && node ./scripts/build-ui.mjs", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*", + "@pierre/diffs": "^1.1.22" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "esbuild": "^0.27.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } +} diff --git a/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs b/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/scripts/build-ui.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, ".."); + +await esbuild.build({ + entryPoints: [path.join(packageRoot, "src/ui/index.tsx")], + outfile: path.join(packageRoot, "dist/ui/index.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@paperclipai/plugin-sdk/ui", + ], + logLevel: "info", +}); diff --git a/packages/plugins/plugin-workspace-diff/src/contracts.ts b/packages/plugins/plugin-workspace-diff/src/contracts.ts new file mode 100644 index 00000000..6267b9b8 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/contracts.ts @@ -0,0 +1,144 @@ +import { z } from "@paperclipai/plugin-sdk"; + +export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]); + +export const workspaceDiffFileStatusSchema = z.enum([ + "added", + "modified", + "deleted", + "renamed", + "copied", + "type_changed", + "untracked", + "unknown", +]); + +export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]); + +export const workspaceDiffWarningCodeSchema = z.enum([ + "base_ref_missing", + "base_ref_invalid", + "binary_file", + "file_count_truncated", + "file_oversized", + "git_command_failed", + "missing_cwd", + "non_git_workspace", + "patch_truncated", + "path_filter_invalid", + "symlink_target_outside_workspace", + "workspace_path_invalid", +]); + +const queryBooleanSchema = z + .union([z.boolean(), z.enum(["true", "false"])]) + .transform((value) => value === true || value === "true"); + +function normalizePathQuery(value: unknown): string[] { + if (value == null) return []; + const values = Array.isArray(value) ? value : [value]; + return values.flatMap((entry) => { + if (typeof entry !== "string") return []; + return entry + .split(",") + .map((filePath) => filePath.trim()) + .filter(Boolean); + }); +} + +export const workspaceDiffQuerySchema = z + .object({ + view: workspaceDiffViewSchema.optional().default("working-tree"), + baseRef: z.string().trim().min(1).max(240).optional().nullable(), + includeUntracked: queryBooleanSchema.optional().default(true), + path: z.union([z.string(), z.array(z.string())]).optional(), + paths: z.union([z.string(), z.array(z.string())]).optional(), + }) + .passthrough() + .transform((value) => ({ + view: value.view, + baseRef: value.baseRef?.trim() || null, + includeUntracked: value.includeUntracked, + paths: normalizePathQuery(value.paths ?? value.path), + })); + +export const workspaceDiffWarningSchema = z.object({ + code: workspaceDiffWarningCodeSchema, + message: z.string(), + path: z.string().nullable(), +}).strict(); + +export const workspaceDiffCapsSchema = z.object({ + maxFiles: z.number().int().positive(), + maxFileBytes: z.number().int().positive(), + maxPatchBytes: z.number().int().positive(), + maxTotalPatchBytes: z.number().int().positive(), +}).strict(); + +export const workspaceDiffFilePatchSchema = z.object({ + kind: workspaceDiffPatchKindSchema, + patch: z.string().nullable(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + binary: z.boolean(), + oversized: z.boolean(), + truncated: z.boolean(), + warnings: z.array(workspaceDiffWarningSchema), +}).strict(); + +export const workspaceDiffFileSchema = z.object({ + path: z.string(), + oldPath: z.string().nullable(), + status: workspaceDiffFileStatusSchema, + staged: z.boolean(), + unstaged: z.boolean(), + untracked: z.boolean(), + binary: z.boolean(), + oversized: z.boolean(), + truncated: z.boolean(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + sizeBytes: z.number().int().nonnegative().nullable(), + patches: z.array(workspaceDiffFilePatchSchema), + warnings: z.array(workspaceDiffWarningSchema), +}).strict(); + +export const workspaceDiffStatsSchema = z.object({ + fileCount: z.number().int().nonnegative(), + stagedFileCount: z.number().int().nonnegative(), + unstagedFileCount: z.number().int().nonnegative(), + untrackedFileCount: z.number().int().nonnegative(), + binaryFileCount: z.number().int().nonnegative(), + oversizedFileCount: z.number().int().nonnegative(), + truncatedFileCount: z.number().int().nonnegative(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), +}).strict(); + +export const workspaceDiffResponseSchema = z.object({ + workspaceId: z.string(), + companyId: z.string(), + view: workspaceDiffViewSchema, + baseRef: z.string().nullable(), + defaultBaseRef: z.string().nullable(), + headSha: z.string().nullable(), + includeUntracked: z.boolean(), + paths: z.array(z.string()), + files: z.array(workspaceDiffFileSchema), + stats: workspaceDiffStatsSchema, + warnings: z.array(workspaceDiffWarningSchema), + caps: workspaceDiffCapsSchema, + truncated: z.boolean(), +}).strict(); + +export type WorkspaceDiffView = z.infer; +export type WorkspaceDiffFileStatus = z.infer; +export type WorkspaceDiffPatchKind = z.infer; +export type WorkspaceDiffWarningCode = z.infer; +export type WorkspaceDiffQueryOptions = z.infer; +export type WorkspaceDiffWarning = z.infer; +export type WorkspaceDiffCaps = z.infer; +export type WorkspaceDiffFilePatch = z.infer; +export type WorkspaceDiffFile = z.infer; +export type WorkspaceDiffStats = z.infer; +export type WorkspaceDiffResponse = z.infer; diff --git a/packages/plugins/plugin-workspace-diff/src/diff-model.ts b/packages/plugins/plugin-workspace-diff/src/diff-model.ts new file mode 100644 index 00000000..b8dfa464 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts @@ -0,0 +1,143 @@ +import type { + WorkspaceDiffFile, + WorkspaceDiffFilePatch, + WorkspaceDiffResponse, + WorkspaceDiffWarning, +} from "./contracts.js"; + +export type DiffRenderMode = "unified" | "split"; + +export interface DiffPatchViewModel { + kind: WorkspaceDiffFilePatch["kind"]; + patch: string | null; + lineCount: number; + additions: number; + deletions: number; + binary: boolean; + oversized: boolean; + truncated: boolean; + warnings: WorkspaceDiffWarning[]; +} + +export interface DiffFileViewModel { + path: string; + oldPath: string | null; + status: WorkspaceDiffFile["status"]; + additions: number; + deletions: number; + binary: boolean; + oversized: boolean; + truncated: boolean; + warnings: WorkspaceDiffWarning[]; + patchKinds: WorkspaceDiffFilePatch["kind"][]; + patches: DiffPatchViewModel[]; + patch: string | null; + lineCount: number; + longDiff: boolean; +} + +export interface DiffSummaryViewModel { + changedLabel: string; + lineLabel: string; + warningCount: number; + truncated: boolean; +} + +const STATUS_LABELS: Record = { + added: "Added", + modified: "Modified", + deleted: "Deleted", + renamed: "Renamed", + copied: "Copied", + type_changed: "Type changed", + untracked: "Untracked", + unknown: "Changed", +}; + +export const LONG_DIFF_LINE_THRESHOLD = 400; + +export function statusLabel(status: WorkspaceDiffFile["status"]) { + return STATUS_LABELS[status] ?? "Changed"; +} + +export function fileName(filePath: string) { + return filePath.split("/").filter(Boolean).pop() ?? filePath; +} + +export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] { + return file.patches.map((patch) => { + const textPatch = patch.patch?.trimEnd() ?? null; + const lineCount = textPatch ? textPatch.split("\n").length : 0; + return { + kind: patch.kind, + patch: textPatch && textPatch.length > 0 ? textPatch : null, + lineCount, + additions: patch.additions, + deletions: patch.deletions, + binary: patch.binary, + oversized: patch.oversized, + truncated: patch.truncated, + warnings: patch.warnings, + }; + }); +} + +export function buildFilePatch(file: WorkspaceDiffFile): string | null { + return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null; +} + +export function isLongDiffFile(file: Pick) { + return file.lineCount > LONG_DIFF_LINE_THRESHOLD; +} + +export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] { + return (diff?.files ?? []).map((file) => { + const patches = buildFilePatches(file); + const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0); + return { + path: file.path, + oldPath: file.oldPath, + status: file.status, + additions: file.additions, + deletions: file.deletions, + binary: file.binary, + oversized: file.oversized, + truncated: file.truncated, + warnings: file.warnings, + patchKinds: file.patches.map((patch) => patch.kind), + patches, + patch: patches.find((patch) => patch.patch)?.patch ?? null, + lineCount, + longDiff: isLongDiffFile({ lineCount }), + }; + }); +} + +export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel { + const stats = diff?.stats; + const fileCount = stats?.fileCount ?? 0; + const additions = stats?.additions ?? 0; + const deletions = stats?.deletions ?? 0; + const warningCount = diff?.warnings.length ?? 0; + + return { + changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`, + lineLabel: `+${additions} / -${deletions}`, + warningCount, + truncated: Boolean(diff?.truncated), + }; +} + +export function nextExpandedFileSet( + current: ReadonlySet, + filePath: string, +): Set { + const next = new Set(current); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; +} + +export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set { + return new Set(files.filter((file) => !file.longDiff).map((file) => file.path)); +} diff --git a/packages/plugins/plugin-workspace-diff/src/index.ts b/packages/plugins/plugin-workspace-diff/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/plugin-workspace-diff/src/manifest.ts b/packages/plugins/plugin-workspace-diff/src/manifest.ts new file mode 100644 index 00000000..107ecfbf --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/manifest.ts @@ -0,0 +1,37 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const PLUGIN_ID = "paperclip.workspace-diff"; +const CHANGES_TAB_SLOT_ID = "workspace-changes-tab"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.1.0", + displayName: "Workspace Changes", + description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "ui.detailTab.register", + "execution.workspaces.read", + "project.workspaces.read", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "detailTab", + id: CHANGES_TAB_SLOT_ID, + displayName: "Changes", + exportName: "ChangesTab", + entityTypes: ["execution_workspace", "project_workspace"], + order: 25, + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx new file mode 100644 index 00000000..40b96249 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -0,0 +1,824 @@ +import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; +import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui"; +import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; +import type { PatchDiffProps } from "@pierre/diffs/react"; +import { useFileDiffInstance } from "@pierre/diffs/react"; +import { + createElement, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + diffSummary, + fileName, + initialExpandedFileSet, + nextExpandedFileSet, + statusLabel, + toFileViewModels, + type DiffFileViewModel, + type DiffPatchViewModel, + type DiffRenderMode, +} from "../diff-model.js"; +import type { WorkspaceDiffResponse } from "../contracts.js"; + +type WorkspaceDiffData = WorkspaceDiffResponse; +type WorkspacePatchDiffOptions = PatchDiffProps["options"]; +type DiffViewMode = "working-tree" | "head"; + +type LucideIconProps = { size?: number }; + +const DEFAULT_FILE_SIDEBAR_WIDTH = 280; +const MIN_FILE_SIDEBAR_WIDTH = 220; +const MAX_FILE_SIDEBAR_WIDTH = 520; +const FILE_SIDEBAR_WIDTH_STEP = 16; +const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width"; + +function makeLucideIcon(paths: ReactNode) { + return function LucideIcon({ size = 16 }: LucideIconProps) { + return ( + + ); + }; +} + +// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw. +const RefreshCwIcon = makeLucideIcon( + <> + + + + + , +); + +function readInitialView(): DiffViewMode { + if (typeof window === "undefined") return "working-tree"; + return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree"; +} + +function hasInitialViewParam() { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).has("diffView"); +} + +function readInitialBaseRef() { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("baseRef") ?? ""; +} + +function buttonClass(active = false) { + return [ + "inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors", + active + ? "border-foreground/20 bg-foreground text-background" + : "border-border bg-background text-muted-foreground hover:text-foreground", + ].join(" "); +} + +function iconButtonClass(active = false) { + return [ + "inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors", + active + ? "border-foreground/20 bg-foreground text-background" + : "border-border bg-background text-muted-foreground hover:text-foreground", + ].join(" "); +} + +function clampFileSidebarWidth(width: number) { + return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width)); +} + +function readStoredFileSidebarWidth() { + if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH; + + try { + const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY); + if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH; + const parsed = Number.parseInt(stored, 10); + return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH; + } catch { + return DEFAULT_FILE_SIDEBAR_WIDTH; + } +} + +function writeStoredFileSidebarWidth(width: number) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width))); + } catch { + // Storage can be unavailable; keep resize interactive even when persistence fails. + } +} + +function useIsDesktopDiffLayout() { + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(min-width: 1024px)").matches; + }); + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + + const query = window.matchMedia("(min-width: 1024px)"); + const update = () => setIsDesktop(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + return isDesktop; +} + +function warningText(file: DiffFileViewModel) { + if (file.binary) return "Binary file"; + if (file.oversized) return "Too large to render"; + if (file.truncated) return "Patch truncated"; + if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning"; + if (file.patches.every((patch) => !patch.patch)) return "No text patch"; + return null; +} + +const PATCH_KIND_LABELS: Record = { + staged: "Staged", + unstaged: "Unstaged", + head: "Head", + untracked: "Untracked", +}; + +function patchKindLabel(kind: DiffPatchViewModel["kind"]) { + return PATCH_KIND_LABELS[kind] ?? "Patch"; +} + +function patchWarningText(patch: DiffPatchViewModel) { + if (patch.binary) return "Binary file"; + if (patch.oversized) return "Too large to render"; + if (patch.truncated) return "Patch truncated"; + if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning"; + if (!patch.patch) return "No text patch"; + return null; +} + +function FileRow({ + file, + active, + expanded, + onSelect, + onToggle, + onCopy, +}: { + file: DiffFileViewModel; + active: boolean; + expanded: boolean; + onSelect: () => void; + onToggle: () => void; + onCopy: () => void; +}) { + const warning = warningText(file); + const expandLabel = expanded ? "Collapse file" : "Expand file"; + const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`; + + return ( +
+
+ + + +
+
+ {statusLabel(file.status)} + {`+${file.additions}`} + {`-${file.deletions}`} + {warning ? {warning} : null} +
+
+ ); +} + +// The upstream React wrapper emits React 19 key warnings for its internal slot array. +// This mounts the same Diffs custom element through the exported imperative hook. +function WorkspacePatchDiff({ + patch, + options, +}: { + patch: string; + options: WorkspacePatchDiffOptions; +}) { + const fileDiff = useMemo(() => getSingularPatch(patch), [patch]); + const { ref } = useFileDiffInstance({ + fileDiff, + options, + metrics: undefined, + lineAnnotations: undefined, + selectedLines: undefined, + prerenderedHTML: undefined, + hasGutterRenderUtility: false, + hasCustomHeader: false, + disableWorkerPool: false, + }); + + return createElement(DIFFS_TAG_NAME, { ref }); +} + +function EmptyState() { + return ( +
+
No workspace changes
+
+ The workspace matches its current comparison target. +
+
+ ); +} + +function LoadingState() { + return ( +
+ Loading workspace changes… +
+ ); +} + +export function ErrorState({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+
+
+
Unable to load workspace changes.
+
+ Retry the request or open the details below for the technical error. +
+
+ +
+
+ + Troubleshooting details + +
+          {message || "No error message was provided."}
+        
+
+
+ ); +} + +function FileDiffPanel({ + file, + mode, + lineWrap, +}: { + file: DiffFileViewModel; + mode: DiffRenderMode; + lineWrap: boolean; +}) { + const warning = warningText(file); + if (warning) { + return ( +
+ {warning ?? "No renderable patch is available for this file."} +
+ ); + } + + return ( +
+ {file.patches.map((patch, index) => { + const patchWarning = patchWarningText(patch); + return ( +
+ {file.patches.length > 1 ? ( +
+ {patchKindLabel(patch.kind)} + {`+${patch.additions}`} + {`-${patch.deletions}`} +
+ ) : null} + {patchWarning || !patch.patch ? ( +
+ {patchWarning ?? "No renderable patch is available for this file."} +
+ ) : ( + + )} +
+ ); + })} +
+ ); +} + +function CollapsedFilePanel({ + file, + onExpand, +}: { + file: DiffFileViewModel; + onExpand: () => void; +}) { + const title = file.longDiff ? "Large diff folded" : "Diff folded"; + const details = file.lineCount > 0 + ? `${file.lineCount.toLocaleString()} lines` + : statusLabel(file.status); + + return ( +
+
+
+
{title}
+
{details}
+
+ +
+
+ ); +} + +export function ChangesTab({ context }: PluginDetailTabProps) { + const toast = usePluginToast(); + const [mode, setMode] = useState("split"); + const [lineWrap, setLineWrap] = useState(false); + const [view, setView] = useState(() => readInitialView()); + const [baseRef, setBaseRef] = useState(() => readInitialBaseRef()); + const baseRefTouchedRef = useRef(Boolean(baseRef.trim())); + const viewTouchedRef = useRef(hasInitialViewParam()); + const [includeUntracked, setIncludeUntracked] = useState(false); + const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); + const [selectedPath, setSelectedPath] = useState(null); + const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth()); + const [fileSidebarResizing, setFileSidebarResizing] = useState(false); + const fileSidebarWidthRef = useRef(fileSidebarWidth); + const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null); + const fileSectionRefs = useRef(new Map()); + const diffScrollRef = useRef(null); + const scrollSyncFrameRef = useRef(null); + const usesDesktopDiffLayout = useIsDesktopDiffLayout(); + const requestedBaseRef = baseRef.trim(); + const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view; + const fileSidebarStyle = useMemo( + () => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined, + [fileSidebarWidth, usesDesktopDiffLayout], + ); + + const params = useMemo(() => ({ + workspaceId: context.entityId, + companyId: context.companyId ?? "", + projectId: context.projectId ?? "", + entityType: context.entityType, + view: effectiveView, + baseRef: requestedBaseRef || null, + includeUntracked, + }), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]); + + const { data, loading, error, refresh } = usePluginData("workspace-diff", params); + const files = useMemo(() => toFileViewModels(data), [data]); + const summary = useMemo(() => diffSummary(data), [data]); + const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null; + const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`; + + const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => { + if (node) fileSectionRefs.current.set(filePath, node); + else fileSectionRefs.current.delete(filePath); + }, []); + + const selectFile = useCallback((filePath: string) => { + setSelectedPath(filePath); + window.requestAnimationFrame(() => { + fileSectionRefs.current.get(filePath)?.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }); + }, []); + + const syncSelectedPathFromScroll = useCallback(() => { + const container = diffScrollRef.current; + if (!container || files.length === 0) return; + + const containerTop = container.getBoundingClientRect().top; + let nextPath = files[0]?.path ?? null; + for (const file of files) { + const section = fileSectionRefs.current.get(file.path); + if (!section) continue; + const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop; + if (offsetFromScrollTop <= 48) { + nextPath = file.path; + } else { + break; + } + } + + if (nextPath) { + setSelectedPath((current) => current === nextPath ? current : nextPath); + } + }, [files]); + + const handleDiffScroll = useCallback(() => { + if (scrollSyncFrameRef.current !== null) return; + scrollSyncFrameRef.current = window.requestAnimationFrame(() => { + scrollSyncFrameRef.current = null; + syncSelectedPathFromScroll(); + }); + }, [syncSelectedPathFromScroll]); + + const commitFileSidebarWidth = useCallback((nextWidth: number) => { + const clamped = clampFileSidebarWidth(nextWidth); + fileSidebarWidthRef.current = clamped; + setFileSidebarWidth(clamped); + writeStoredFileSidebarWidth(clamped); + }, []); + + const handleFileSidebarPointerDown = useCallback((event: PointerEvent) => { + if (!usesDesktopDiffLayout) return; + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + fileSidebarDragRef.current = { + startX: event.clientX, + startWidth: fileSidebarWidthRef.current, + }; + setFileSidebarResizing(true); + }, [usesDesktopDiffLayout]); + + const handleFileSidebarPointerMove = useCallback((event: PointerEvent) => { + const drag = fileSidebarDragRef.current; + if (!drag) return; + + const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX); + fileSidebarWidthRef.current = nextWidth; + setFileSidebarWidth(nextWidth); + }, []); + + const endFileSidebarResize = useCallback(() => { + if (!fileSidebarDragRef.current) return; + + fileSidebarDragRef.current = null; + setFileSidebarResizing(false); + writeStoredFileSidebarWidth(fileSidebarWidthRef.current); + }, []); + + const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent) => { + if (!usesDesktopDiffLayout) return; + + if (event.key === "ArrowLeft") { + event.preventDefault(); + commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP); + } else if (event.key === "Home") { + event.preventDefault(); + commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH); + } else if (event.key === "End") { + event.preventDefault(); + commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH); + } + }, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]); + + useEffect(() => { + const defaultBaseRef = data?.defaultBaseRef?.trim(); + if (!defaultBaseRef) return; + if (!baseRef.trim() && !baseRefTouchedRef.current) { + setBaseRef(defaultBaseRef); + } + if (view === "working-tree" && !viewTouchedRef.current) { + setView("head"); + } + }, [baseRef, data?.defaultBaseRef, view]); + + useEffect(() => { + if (files.length === 0) { + setExpandedFiles(new Set()); + setSelectedPath(null); + return; + } + setExpandedFiles(initialExpandedFileSet(files)); + setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null); + }, [files]); + + useEffect(() => { + return () => { + if (scrollSyncFrameRef.current !== null) { + window.cancelAnimationFrame(scrollSyncFrameRef.current); + } + }; + }, []); + + useEffect(() => { + if (!fileSidebarResizing || typeof document === "undefined") return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + }; + }, [fileSidebarResizing]); + + const copyPath = async (filePath: string) => { + try { + await navigator.clipboard.writeText(filePath); + toast({ title: "Path copied", body: filePath }); + } catch { + toast({ title: "Copy failed", body: filePath, tone: "error" }); + } + }; + + return ( +
+
+
+
+ {summary.changedLabel} + {summary.lineLabel} + {summary.truncated ? ( + Truncated + ) : null} + {summary.warningCount > 0 ? ( + {summary.warningCount} warnings + ) : null} +
+
+ {compareLabel} +
+
+ +
+
+ + +
+ +
+ + +
+ {view === "head" ? ( + { + baseRefTouchedRef.current = true; + setBaseRef(event.target.value); + }} + placeholder="origin/master" + aria-label="Base ref" + /> + ) : null} + {view === "working-tree" ? ( + + ) : null} + +
+
+ + {loading ? ( + + ) : error ? ( + + ) : files.length === 0 ? ( + + ) : ( +
+ + +
+ {files + .map((file, index) => ( +
+
+
+ + +
+
+ +
+
+ {expandedFiles.has(file.path) ? ( + + ) : ( + setExpandedFiles((current) => nextExpandedFileSet(current, file.path))} + /> + )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts new file mode 100644 index 00000000..d8de9c48 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -0,0 +1,108 @@ +import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk"; +import { workspaceDiffQuerySchema } from "./contracts.js"; +import { workspaceDiffService } from "./workspace-diff.js"; + +const PLUGIN_NAME = "workspace-diff"; + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function readOptionalString(value: unknown): string | null { + const trimmed = readString(value); + return trimmed || null; +} + +export function resolveDefaultBaseRef(input: { + workspaceBaseRef?: unknown; + projectWorkspaceDefaultRef?: unknown; + projectWorkspaceRepoRef?: unknown; +}): string | null { + return readOptionalString(input.workspaceBaseRef) + ?? readOptionalString(input.projectWorkspaceDefaultRef) + ?? readOptionalString(input.projectWorkspaceRepoRef); +} + +async function resolveProjectWorkspaceDefaultBaseRef(input: { + ctx: PluginContext; + projectId: string; + companyId: string; + projectWorkspaceId?: string | null; +}): Promise { + if (!input.projectId) return null; + const workspaces = await input.ctx.projects.listWorkspaces(input.projectId, input.companyId); + const projectWorkspace = input.projectWorkspaceId + ? workspaces.find((candidate) => candidate.id === input.projectWorkspaceId) + : workspaces.find((candidate) => candidate.isPrimary) ?? workspaces[0] ?? null; + return projectWorkspace + ? resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: projectWorkspace.defaultRef, + projectWorkspaceRepoRef: projectWorkspace.repoRef, + }) + : null; +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup`); + const workspaceDiff = workspaceDiffService(); + + ctx.data.register("workspace-diff", async (params: Record) => { + const workspaceId = readString(params.workspaceId); + const companyId = readString(params.companyId); + if (!workspaceId || !companyId) { + throw new Error("workspaceId and companyId are required"); + } + + if (params.entityType === "project_workspace") { + const projectId = readString(params.projectId); + if (!projectId) { + throw new Error("projectId is required for project workspace diffs"); + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((candidate) => candidate.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found"); + } + return workspaceDiff.getDiff({ + id: workspace.id, + companyId, + cwd: workspace.path, + baseRef: resolveDefaultBaseRef({ + projectWorkspaceDefaultRef: workspace.defaultRef, + projectWorkspaceRepoRef: workspace.repoRef, + }), + }, workspaceDiffQuerySchema.parse(params)); + } + + const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId); + if (!workspace) { + throw new Error("Workspace not found"); + } + let projectWorkspaceDefaultBaseRef: string | null = null; + if (!readOptionalString(workspace.baseRef)) { + projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({ + ctx, + projectId: workspace.projectId || readString(params.projectId), + companyId, + projectWorkspaceId: workspace.projectWorkspaceId, + }); + } + + return workspaceDiff.getDiff({ + ...workspace, + baseRef: resolveDefaultBaseRef({ + workspaceBaseRef: workspace.baseRef, + projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef, + }), + }, workspaceDiffQuerySchema.parse(params)); + }); + }, + + async onHealth() { + return { status: "ok", message: `${PLUGIN_NAME} ready` }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts new file mode 100644 index 00000000..55dccc7e --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -0,0 +1,845 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk"; +import type { + WorkspaceDiffCaps, + WorkspaceDiffFile, + WorkspaceDiffFilePatch, + WorkspaceDiffFileStatus, + WorkspaceDiffPatchKind, + WorkspaceDiffQueryOptions, + WorkspaceDiffResponse, + WorkspaceDiffWarning, + WorkspaceDiffWarningCode, +} from "./contracts.js"; + +const execFileAsync = promisify(execFile); + +export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = { + maxFiles: 200, + maxFileBytes: 512 * 1024, + maxPatchBytes: 256 * 1024, + maxTotalPatchBytes: 1024 * 1024, +}; + +const GIT_TIMEOUT_MS = 10_000; +const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024; +const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0; + +interface GitStatusEntry { + status: WorkspaceDiffFileStatus; + path: string; + oldPath: string | null; +} + +type DiffScope = "staged" | "unstaged" | "head"; + +interface MutableWorkspaceDiffFile extends WorkspaceDiffFile { + patchScopes: DiffScope[]; +} + +interface PatchBudget { + totalPatchBytes: number; +} + +type WorkspaceDiffTarget = Pick; + +function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning { + return { code, message, path: filePath }; +} + +function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record = {}) { + const error = new Error(message); + Object.assign(error, { code, status: 422, details: { code, ...details } }); + return error; +} + +function toErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + return String(error); +} + +async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) { + try { + return await execFileAsync("git", ["-C", cwd, ...args], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer, + }); + } catch (error) { + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? String((error as { stderr?: unknown }).stderr).trim() + : ""; + const message = stderr || toErrorMessage(error); + throw workspaceDiffError("git_command_failed", message, { args }); + } +} + +async function realDirectory(value: string, code: WorkspaceDiffWarningCode) { + if (!path.isAbsolute(value)) { + throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value }); + } + let stat: Awaited>; + try { + stat = await fs.stat(value); + } catch { + throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value }); + } + if (!stat.isDirectory()) { + throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value }); + } + return await fs.realpath(value); +} + +function isWithinDirectory(childPath: string, parentPath: string) { + const relative = path.relative(parentPath, childPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) { + if (!workspace.cwd?.trim()) { + throw workspaceDiffError( + "missing_cwd", + "Execution workspace needs a local path before Paperclip can inspect diffs", + { workspaceId: workspace.id }, + ); + } + + const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid"); + let repoRoot: string; + try { + repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim(); + } catch { + throw workspaceDiffError( + "non_git_workspace", + "Execution workspace path is not inside a git repository", + { workspaceId: workspace.id, cwd }, + ); + } + + const repoRootReal = await realDirectory(repoRoot, "non_git_workspace"); + if (!isWithinDirectory(cwd, repoRootReal)) { + throw workspaceDiffError( + "workspace_path_invalid", + "Execution workspace path resolved outside its git repository", + { workspaceId: workspace.id, cwd, repoRoot: repoRootReal }, + ); + } + + return { cwd, repoRoot: repoRootReal }; +} + +function normalizePathFilter(rawPath: string) { + const value = rawPath.trim().replaceAll("\\", "/"); + if (!value || value === ".") return null; + if (value.includes("\0") || value.startsWith("/")) { + throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath }); + } + const normalized = path.posix.normalize(value); + if ( + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + normalized.includes("/../") + ) { + throw workspaceDiffError( + "path_filter_invalid", + "Path filters must not contain traversal segments", + { path: rawPath }, + ); + } + return normalized; +} + +function normalizePathFilters(paths: string[]) { + return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value)))); +} + +function statusFromGitStatus(status: string): WorkspaceDiffFileStatus { + if (status.startsWith("R")) return "renamed"; + if (status.startsWith("C")) return "copied"; + switch (status[0]) { + case "A": + return "added"; + case "D": + return "deleted"; + case "M": + return "modified"; + case "T": + return "type_changed"; + default: + return "unknown"; + } +} + +function parseNameStatus(output: string): GitStatusEntry[] { + const tokens = output.split("\0").filter(Boolean); + const entries: GitStatusEntry[] = []; + let index = 0; + while (index < tokens.length) { + const statusCode = tokens[index++] ?? ""; + if (!statusCode) continue; + if (statusCode.startsWith("R") || statusCode.startsWith("C")) { + const oldPath = tokens[index++] ?? ""; + const newPath = tokens[index++] ?? ""; + if (newPath) { + entries.push({ + status: statusFromGitStatus(statusCode), + path: newPath, + oldPath: oldPath || null, + }); + } + continue; + } + + const filePath = tokens[index++] ?? ""; + if (filePath) { + entries.push({ + status: statusFromGitStatus(statusCode), + path: filePath, + oldPath: null, + }); + } + } + return entries; +} + +async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) { + const result = await runGit(cwd, [ + "diff", + "--name-status", + "-z", + "--no-ext-diff", + "--find-renames", + ...scopeArgs, + "--", + ...paths, + ]); + return parseNameStatus(result.stdout); +} + +async function readUntrackedPaths(cwd: string, paths: string[]) { + const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]); + return result.stdout.split("\0").filter(Boolean); +} + +function ensureFile( + files: Map, + filePath: string, + status: WorkspaceDiffFileStatus, + oldPath: string | null, +) { + const existing = files.get(filePath); + if (existing) { + if (existing.status === "unknown" || status === "renamed" || status === "copied") { + existing.status = status; + } + if (!existing.oldPath && oldPath) existing.oldPath = oldPath; + return existing; + } + + const file: MutableWorkspaceDiffFile = { + path: filePath, + oldPath, + status, + staged: false, + unstaged: false, + untracked: false, + binary: false, + oversized: false, + truncated: false, + additions: 0, + deletions: 0, + sizeBytes: null, + patches: [], + warnings: [], + patchScopes: [], + }; + files.set(filePath, file); + return file; +} + +function addStatusEntries( + files: Map, + entries: GitStatusEntry[], + scope: DiffScope, +) { + for (const entry of entries) { + const file = ensureFile(files, entry.path, entry.status, entry.oldPath); + if (scope === "staged") file.staged = true; + else if (scope === "unstaged") file.unstaged = true; + if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope); + } +} + +function parseNumstat(output: string) { + const line = output.split(/\r?\n/).find(Boolean); + if (!line) return { additions: 0, deletions: 0, binary: false }; + const [additionsRaw, deletionsRaw] = line.split(/\t/); + if (additionsRaw === "-" || deletionsRaw === "-") { + return { additions: 0, deletions: 0, binary: true }; + } + return { + additions: Number.parseInt(additionsRaw ?? "0", 10) || 0, + deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0, + binary: false, + }; +} + +async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) { + const result = await runGit(cwd, [ + "diff", + "--numstat", + "--no-ext-diff", + "--find-renames", + ...scopeArgs, + "--", + filePath, + ], 128 * 1024); + return parseNumstat(result.stdout); +} + +async function statWorkspaceFile(repoRoot: string, filePath: string) { + const resolved = await resolveWorkspaceFilePath(repoRoot, filePath); + if (resolved.status !== "ok") return null; + let handle: Awaited>; + try { + handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW); + } catch { + return null; + } + try { + const stat = await handle.stat(); + return stat.isFile() ? stat.size : null; + } catch { + return null; + } finally { + await handle.close(); + } +} + +async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise< + | { status: "ok"; realPath: string } + | { status: "missing" } + | { status: "outside_workspace" } +> { + const target = path.resolve(repoRoot, filePath); + if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" }; + try { + const realPath = await fs.realpath(target); + if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" }; + return { status: "ok", realPath }; + } catch { + return { status: "missing" }; + } +} + +function isMaxBufferError(error: unknown) { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; +} + +async function readPatchOutput(cwd: string, args: string[]) { + try { + return await execFileAsync("git", ["-C", cwd, ...args], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024, + }); + } catch (error) { + if (isMaxBufferError(error)) { + return null; + } + const stderr = typeof (error as { stderr?: unknown }).stderr === "string" + ? String((error as { stderr?: unknown }).stderr).trim() + : ""; + throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args }); + } +} + +function reservePatchBytes( + patch: string, + budget: PatchBudget, + filePath: string, + warnings: WorkspaceDiffWarning[], +) { + const patchBytes = Buffer.byteLength(patch, "utf8"); + if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) { + warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath)); + return null; + } + if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) { + warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath)); + return null; + } + budget.totalPatchBytes += patchBytes; + return patch; +} + +async function buildTrackedPatch(input: { + cwd: string; + repoRoot: string; + filePath: string; + kind: WorkspaceDiffPatchKind; + scopeArgs: string[]; + budget: PatchBudget; +}): Promise { + const warnings: WorkspaceDiffWarning[] = []; + const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath); + const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath); + + if (numstat.binary) { + warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: 0, + deletions: 0, + binary: true, + oversized: false, + truncated: false, + warnings, + }; + } + + if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { + warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: true, + truncated: false, + warnings, + }; + } + + const patchOutput = await readPatchOutput(input.cwd, [ + "diff", + "--no-ext-diff", + "--find-renames", + "--unified=3", + ...input.scopeArgs, + "--", + input.filePath, + ]); + if (!patchOutput) { + warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath)); + return { + kind: input.kind, + patch: null, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: false, + truncated: true, + warnings, + }; + } + + const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings); + return { + kind: input.kind, + patch, + additions: numstat.additions, + deletions: numstat.deletions, + binary: false, + oversized: false, + truncated: patch === null, + warnings, + }; +} + +function isProbablyBinary(buffer: Buffer) { + return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0); +} + +function countAddedLines(content: string) { + if (content.length === 0) return 0; + return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length; +} + +function buildUntrackedPatch(filePath: string, content: string) { + const lines = content.length === 0 ? [] : content.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const lineCount = countAddedLines(content); + const header = [ + `diff --git a/${filePath} b/${filePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${filePath}`, + ]; + if (lineCount === 0) return `${header.join("\n")}\n`; + const hunkLines = lines.map((line) => `+${line}`).join("\n"); + return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n"); +} + +async function buildUntrackedFilePatch(input: { + repoRoot: string; + filePath: string; + budget: PatchBudget; +}): Promise { + const warnings: WorkspaceDiffWarning[] = []; + const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath); + if (resolved.status === "outside_workspace") { + warnings.push(warning( + "symlink_target_outside_workspace", + "Untracked file resolves outside the workspace and is summarized without reading target bytes.", + input.filePath, + )); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + if (resolved.status === "missing") { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + + let handle: Awaited>; + try { + handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW); + } catch { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + + let sizeBytes: number; + let buffer: Buffer | null = null; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + sizeBytes = stat.size; + if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) { + buffer = await handle.readFile(); + } + } finally { + await handle.close(); + } + + if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) { + warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath)); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: true, + truncated: false, + warnings, + }; + } + + if (!buffer) { + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings, + }; + } + if (isProbablyBinary(buffer)) { + warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath)); + return { + kind: "untracked", + patch: null, + additions: 0, + deletions: 0, + binary: true, + oversized: false, + truncated: false, + warnings, + }; + } + + const content = buffer.toString("utf8"); + const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings); + return { + kind: "untracked", + patch, + additions: countAddedLines(content), + deletions: 0, + binary: false, + oversized: false, + truncated: patch === null, + warnings, + }; +} + +function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) { + file.patches.push(patch); + file.additions += patch.additions; + file.deletions += patch.deletions; + file.binary = file.binary || patch.binary; + file.oversized = file.oversized || patch.oversized; + file.truncated = file.truncated || patch.truncated; + file.warnings.push(...patch.warnings); + if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes; +} + +function finalizeStats(files: WorkspaceDiffFile[]) { + return { + fileCount: files.length, + stagedFileCount: files.filter((file) => file.staged).length, + unstagedFileCount: files.filter((file) => file.unstaged).length, + untrackedFileCount: files.filter((file) => file.untracked).length, + binaryFileCount: files.filter((file) => file.binary).length, + oversizedFileCount: files.filter((file) => file.oversized).length, + truncatedFileCount: files.filter((file) => file.truncated).length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }; +} + +async function resolveHeadSha(cwd: string) { + try { + return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null; + } catch { + return null; + } +} + +async function resolveVerifiedGitRef(cwd: string, refName: string) { + const trimmed = refName.trim(); + if (!trimmed) return null; + try { + await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }); + return trimmed; + } catch { + return null; + } +} + +async function resolveGitUpstreamRef(cwd: string) { + try { + const upstream = (await execFileAsync( + "git", + ["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }, + )).stdout.trim(); + return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null; + } catch { + return null; + } +} + +async function resolveInferredDefaultBaseRef(cwd: string) { + const upstream = await resolveGitUpstreamRef(cwd); + if (upstream) return upstream; + + const candidates = ["origin/master", "origin/main", "master", "main"]; + const resolvedCandidates = await Promise.all( + candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)), + ); + for (const resolved of resolvedCandidates) { + if (resolved) return resolved; + } + + return null; +} + +async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) { + return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd); +} + +async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) { + const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null; + if (!resolvedBaseRef) { + throw workspaceDiffError( + "base_ref_missing", + "A baseRef query parameter or execution workspace baseRef is required for head diffs", + { workspaceId: workspace.id }, + ); + } + try { + await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + }); + } catch { + throw workspaceDiffError( + "base_ref_invalid", + `Could not resolve baseRef "${resolvedBaseRef}" in this workspace`, + { workspaceId: workspace.id, baseRef: resolvedBaseRef }, + ); + } + return resolvedBaseRef; +} + +async function collectFiles(input: { + cwd: string; + workspace: WorkspaceDiffTarget; + query: WorkspaceDiffQueryOptions; + paths: string[]; +}) { + const files = new Map(); + let baseRef: string | null = null; + + if (input.query.view === "head") { + baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace); + addStatusEntries( + files, + await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths), + "head", + ); + } else { + addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged"); + addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged"); + if (input.query.includeUntracked) { + for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) { + const file = ensureFile(files, untrackedPath, "untracked", null); + file.untracked = true; + if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged"); + } + } + } + + return { files, baseRef }; +} + +export function workspaceDiffService() { + return { + async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise { + const { cwd, repoRoot } = await resolveWorkspacePaths(workspace); + const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace); + const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef }; + const paths = normalizePathFilters(query.paths); + const warnings: WorkspaceDiffWarning[] = []; + const { files: filesByPath, baseRef } = await collectFiles({ + cwd, + workspace: workspaceWithDefaultBaseRef, + query, + paths, + }); + const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path)); + const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles); + if (allFiles.length > cappedFiles.length) { + warnings.push(warning( + "file_count_truncated", + `Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`, + )); + } + + const patchBudget: PatchBudget = { totalPatchBytes: 0 }; + for (const file of cappedFiles) { + if (query.view === "head") { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "head", + scopeArgs: [`${baseRef}...HEAD`], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + continue; + } + + if (file.staged) { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "staged", + scopeArgs: ["--cached"], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + if (file.unstaged) { + const patch = await buildTrackedPatch({ + cwd, + repoRoot, + filePath: file.path, + kind: "unstaged", + scopeArgs: [], + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + if (file.untracked) { + const patch = await buildUntrackedFilePatch({ + repoRoot, + filePath: file.path, + budget: patchBudget, + }); + applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path)); + } + } + + const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file); + const patchWarnings = files.flatMap((file) => file.warnings); + return { + workspaceId: workspace.id, + companyId: workspace.companyId, + view: query.view, + baseRef, + defaultBaseRef, + headSha: await resolveHeadSha(cwd), + includeUntracked: query.includeUntracked, + paths, + files, + stats: finalizeStats(files), + warnings: [...warnings, ...patchWarnings], + caps: WORKSPACE_DIFF_CAPS, + truncated: warnings.some((item) => item.code === "file_count_truncated") + || files.some((file) => file.truncated), + }; + }, + }; +} diff --git a/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts b/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts new file mode 100644 index 00000000..ee6df0e1 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/contracts.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js"; +import { diffResponse } from "./fixtures.js"; + +describe("workspace diff plugin contracts", () => { + it("normalizes query options from plugin data parameters", () => { + expect(workspaceDiffQuerySchema.parse({ + view: "head", + baseRef: " main ", + includeUntracked: "false", + path: ["src/app.ts, README.md", "packages/shared/src/index.ts"], + })).toEqual({ + view: "head", + baseRef: "main", + includeUntracked: false, + paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"], + }); + }); + + it("validates the plugin-owned response shape", () => { + expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({ + workspaceId: "11111111-1111-4111-8111-111111111111", + stats: { fileCount: 1 }, + }); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts new file mode 100644 index 00000000..8ab427c5 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + buildFilePatch, + buildFilePatches, + diffSummary, + initialExpandedFileSet, + LONG_DIFF_LINE_THRESHOLD, + nextExpandedFileSet, + statusLabel, + toFileViewModels, +} from "../src/diff-model.js"; +import { changedFile, diffResponse } from "./fixtures.js"; + +describe("workspace diff UI model", () => { + it("summarizes changed files and line counts", () => { + const diff = diffResponse(); + + expect(diffSummary(diff)).toMatchObject({ + changedLabel: "1 file", + lineLabel: "+1 / -1", + warningCount: 0, + truncated: false, + }); + expect(toFileViewModels(diff)[0]).toMatchObject({ + path: "src/app.ts", + status: "modified", + patchKinds: ["unstaged"], + lineCount: 7, + longDiff: false, + }); + }); + + it("represents empty workspace diffs", () => { + const diff = diffResponse({ files: [] }); + + expect(toFileViewModels(diff)).toEqual([]); + expect(diffSummary(diff).changedLabel).toBe("0 files"); + }); + + it("surfaces truncation and file warnings", () => { + const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" }; + const file = changedFile({ + truncated: true, + warnings: [warning], + patches: [], + }); + const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] }); + + expect(buildFilePatch(file)).toBeNull(); + expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]); + expect(diffSummary(diff)).toMatchObject({ + warningCount: 1, + truncated: true, + }); + }); + + it("does not duplicate aggregated patch warnings", () => { + const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" }; + const file = changedFile({ + warnings: [warning], + patches: [ + { + kind: "unstaged", + patch: null, + additions: 0, + deletions: 0, + binary: false, + oversized: false, + truncated: true, + warnings: [warning], + }, + ], + }); + const diff = diffResponse({ files: [file], warnings: [warning] }); + + expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]); + expect(diffSummary(diff).warningCount).toBe(1); + }); + + it("keeps staged and unstaged patches renderable as separate single-file diffs", () => { + const stagedPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 1111111..2222222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-export const value = 1;", + "+export const value = 2;", + "", + ].join("\n"); + const unstagedPatch = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 2222222..3333333 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -3 +3 @@", + "-export const label = 'old';", + "+export const label = 'new';", + "", + ].join("\n"); + const file = changedFile({ + staged: true, + unstaged: true, + patches: [ + { + kind: "staged", + patch: stagedPatch, + additions: 1, + deletions: 1, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + { + kind: "unstaged", + patch: unstagedPatch, + additions: 1, + deletions: 1, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + }); + + const patches = buildFilePatches(file); + const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0]; + + expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd()); + expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]); + expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]); + expect(viewModel?.patches).toHaveLength(2); + expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]); + }); + + it("marks long text diffs so the UI can fold them by default", () => { + const longPatch = [ + "diff --git a/src/large.ts b/src/large.ts", + "index 1111111..2222222 100644", + "--- a/src/large.ts", + "+++ b/src/large.ts", + "@@ -1,1 +1,1 @@", + ...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`), + "", + ].join("\n"); + const files = toFileViewModels(diffResponse({ + files: [ + changedFile({ path: "src/small.ts" }), + changedFile({ + path: "src/large.ts", + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + patches: [ + { + kind: "unstaged", + patch: longPatch, + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + }), + ], + })); + const longFile = files.find((file) => file.path === "src/large.ts"); + const defaultExpanded = initialExpandedFileSet(files); + + expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD); + expect(longFile?.longDiff).toBe(true); + expect(defaultExpanded.has("src/small.ts")).toBe(true); + expect(defaultExpanded.has("src/large.ts")).toBe(false); + }); + + it("toggles expanded file state without mutating the current set", () => { + const current = new Set(["a.ts"]); + const collapsed = nextExpandedFileSet(current, "a.ts"); + const expanded = nextExpandedFileSet(current, "b.ts"); + + expect(current.has("a.ts")).toBe(true); + expect(collapsed.has("a.ts")).toBe(false); + expect(expanded.has("b.ts")).toBe(true); + }); + + it("labels file statuses for the sidebar", () => { + expect(statusLabel("untracked")).toBe("Untracked"); + expect(statusLabel("type_changed")).toBe("Type changed"); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/fixtures.ts b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts new file mode 100644 index 00000000..9c8eb535 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/fixtures.ts @@ -0,0 +1,78 @@ +import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js"; + +export function changedFile(overrides: Partial = {}): WorkspaceDiffFile { + return { + path: "src/app.ts", + oldPath: null, + status: "modified", + staged: false, + unstaged: true, + untracked: false, + binary: false, + oversized: false, + truncated: false, + additions: 1, + deletions: 1, + sizeBytes: 120, + patches: [ + { + kind: "unstaged", + patch: [ + "diff --git a/src/app.ts b/src/app.ts", + "index 1111111..2222222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-export const value = 1;", + "+export const value = 2;", + "", + ].join("\n"), + additions: 1, + deletions: 1, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + warnings: [], + ...overrides, + }; +} + +export function diffResponse(overrides: Partial = {}): WorkspaceDiffResponse { + const files = overrides.files ?? [changedFile()]; + const additions = files.reduce((sum, file) => sum + file.additions, 0); + const deletions = files.reduce((sum, file) => sum + file.deletions, 0); + return { + workspaceId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + view: "working-tree", + baseRef: null, + defaultBaseRef: null, + headSha: null, + includeUntracked: true, + paths: [], + files, + stats: { + fileCount: files.length, + stagedFileCount: files.filter((file) => file.staged).length, + unstagedFileCount: files.filter((file) => file.unstaged).length, + untrackedFileCount: files.filter((file) => file.untracked).length, + binaryFileCount: files.filter((file) => file.binary).length, + oversizedFileCount: files.filter((file) => file.oversized).length, + truncatedFileCount: files.filter((file) => file.truncated).length, + additions, + deletions, + }, + warnings: [], + caps: { + maxFiles: 200, + maxFileBytes: 524288, + maxPatchBytes: 131072, + maxTotalPatchBytes: 1048576, + }, + truncated: false, + ...overrides, + }; +} diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts new file mode 100644 index 00000000..b9b3dd7b --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -0,0 +1,347 @@ +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin, { resolveDefaultBaseRef } from "../src/worker.js"; + +const execFileAsync = promisify(execFile); +const tempRoots: string[] = []; + +async function git(cwd: string, args: string[]) { + return execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createGitWorkspace() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-")); + tempRoots.push(root); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await git(root, ["init"]); + await git(root, ["config", "user.email", "paperclip@example.com"]); + await git(root, ["config", "user.name", "Paperclip Test"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "initial"]); + await git(root, ["branch", "-M", "main"]); + return root; +} + +describe("workspace diff plugin", () => { + afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; + }); + + it("declares workspace Changes tabs and workspace read capabilities", () => { + expect(manifest.capabilities).toContain("ui.detailTab.register"); + expect(manifest.capabilities).toContain("execution.workspaces.read"); + expect(manifest.capabilities).toContain("project.workspaces.read"); + expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({ + type: "detailTab", + displayName: "Changes", + entityTypes: ["execution_workspace", "project_workspace"], + })); + }); + + it("fetches changed execution workspace diffs from host metadata", async () => { + const root = await createGitWorkspace(); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n"); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "working-tree", + includeUntracked: false, + paths: ["src/app.ts"], + }); + + expect(result).toMatchObject({ + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("returns an empty diff when the workspace has no changes", async () => { + const root = await createGitWorkspace(); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: "HEAD", + branchName: "main", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + })).resolves.toMatchObject({ files: [], truncated: false }); + }); + + it("fetches project workspace diffs from generic project workspace metadata", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "project workspace change"]); + const harness = createTestHarness({ manifest }); + harness.ctx.projects.listWorkspaces = async (projectId, companyId) => { + expect(projectId).toBe("project-1"); + expect(companyId).toBe("company-1"); + return [{ + id: "workspace-1", + projectId: "project-1", + name: "Primary", + path: root, + repoUrl: null, + repoRef: "feature", + defaultRef: "main", + isPrimary: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }]; + }; + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + projectId: "project-1", + entityType: "project_workspace", + view: "head", + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + defaultBaseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("resolves the default base ref from workspace and project workspace metadata", () => { + expect(resolveDefaultBaseRef({ + workspaceBaseRef: " release/main ", + projectWorkspaceDefaultRef: "origin/main", + projectWorkspaceRepoRef: "feature", + })).toBe("release/main"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: null, + projectWorkspaceDefaultRef: " origin/main ", + projectWorkspaceRepoRef: "feature", + })).toBe("origin/main"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: "", + projectWorkspaceDefaultRef: null, + projectWorkspaceRepoRef: " feature ", + })).toBe("feature"); + expect(resolveDefaultBaseRef({ + workspaceBaseRef: "", + projectWorkspaceDefaultRef: null, + projectWorkspaceRepoRef: "", + })).toBeNull(); + }); + + it("uses project workspace default refs for execution workspace head diffs", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "feature change"]); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + path: root, + cwd: root, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + harness.ctx.projects.listWorkspaces = async (projectId, companyId) => { + expect(projectId).toBe("project-1"); + expect(companyId).toBe("company-1"); + return [{ + id: "project-workspace-1", + projectId: "project-1", + name: "Primary", + path: root, + repoUrl: null, + repoRef: "feature", + defaultRef: "main", + isPrimary: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }]; + }; + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "head", + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + defaultBaseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("uses the primary project workspace default ref when execution workspace has no workspace link", async () => { + const root = await createGitWorkspace(); + await git(root, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 5;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "feature change"]); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + harness.ctx.projects.listWorkspaces = async (projectId, companyId) => { + expect(projectId).toBe("project-1"); + expect(companyId).toBe("company-1"); + return [{ + id: "project-workspace-1", + projectId: "project-1", + name: "Primary", + path: root, + repoUrl: null, + repoRef: "feature", + defaultRef: "main", + isPrimary: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }]; + }; + await plugin.definition.setup(harness.ctx); + + const result = await harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + projectId: "project-1", + view: "head", + baseRef: null, + includeUntracked: false, + }); + + expect(result).toMatchObject({ + baseRef: "main", + defaultBaseRef: "main", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("infers the default base ref from the execution workspace branch upstream", async () => { + const root = await createGitWorkspace(); + await git(root, ["update-ref", "refs/remotes/origin/master", "HEAD"]); + await git(root, ["checkout", "-b", "feature"]); + await git(root, ["config", "branch.feature.remote", "origin"]); + await git(root, ["config", "branch.feature.merge", "refs/heads/master"]); + await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 6;\n"); + await git(root, ["add", "src/app.ts"]); + await git(root, ["commit", "-m", "feature change"]); + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: root, + cwd: root, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + }], + }); + await plugin.definition.setup(harness.ctx); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "working-tree", + includeUntracked: false, + })).resolves.toMatchObject({ + baseRef: null, + defaultBaseRef: "origin/master", + stats: { fileCount: 0 }, + }); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + companyId: "company-1", + view: "head", + baseRef: null, + includeUntracked: false, + })).resolves.toMatchObject({ + baseRef: "origin/master", + defaultBaseRef: "origin/master", + stats: { fileCount: 1 }, + files: [expect.objectContaining({ path: "src/app.ts" })], + }); + }); + + it("returns a clear bridge error when required context is missing", async () => { + const harness = createTestHarness({ manifest }); + await plugin.definition.setup(harness.ctx); + + await expect(harness.getData("workspace-diff", { + workspaceId: "workspace-1", + })).rejects.toThrow("workspaceId and companyId are required"); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts b/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts new file mode 100644 index 00000000..b81e70c8 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/ui-error-state.spec.ts @@ -0,0 +1,20 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ErrorState } from "../src/ui/index.js"; + +describe("workspace diff error state", () => { + it("keeps bridge error details out of the primary headline", () => { + const rawError = "Execution workspace not found"; + const html = renderToStaticMarkup(createElement(ErrorState, { + message: rawError, + onRetry: () => undefined, + })); + + expect(html).toContain("Unable to load workspace changes."); + expect(html).toContain("Retry"); + expect(html).toContain("Troubleshooting details"); + expect(html).not.toContain(`font-medium text-foreground">${rawError}`); + expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details")); + }); +}); diff --git a/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts new file mode 100644 index 00000000..65bb619f --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tests/workspace-diff.spec.ts @@ -0,0 +1,200 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk"; +import type { WorkspaceDiffQueryOptions } from "../src/contracts.js"; +import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js"; + +const execFileAsync = promisify(execFile); +const tempDirs = new Set(); + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createTempRepo() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-")); + tempDirs.add(repoRoot); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]); + await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3])); + await runGit(repoRoot, ["add", "."]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["branch", "-M", "main"]); + return repoRoot; +} + +function createWorkspace(cwd: string | null, overrides: Partial = {}): PluginExecutionWorkspaceMetadata { + return { + id: randomUUID(), + companyId: randomUUID(), + projectId: randomUUID(), + projectWorkspaceId: null, + path: cwd, + cwd, + repoUrl: null, + baseRef: null, + branchName: "feature", + providerType: "git_worktree", + providerMetadata: null, + ...overrides, + }; +} + +function workingTreeQuery(overrides: Partial = {}): WorkspaceDiffQueryOptions { + return { + view: "working-tree", + baseRef: null, + includeUntracked: true, + paths: [], + ...overrides, + }; +} + +afterEach(async () => { + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("plugin workspace diff service", () => { + it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => { + const repoRoot = await createTempRepo(); + await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8"); + await runGit(repoRoot, ["add", "tracked-staged.txt"]); + await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8"); + await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]); + await fs.rm(path.join(repoRoot, "delete-me.txt")); + await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5])); + await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8"); + await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8"); + + const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery()); + const byPath = new Map(diff.files.map((file) => [file.path, file])); + + expect(diff.view).toBe("working-tree"); + expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 }); + expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]); + expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 }); + expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" }); + expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 }); + expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 }); + expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new"); + expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([ + "diff --git a/empty-untracked.txt b/empty-untracked.txt", + "new file mode 100644", + "--- /dev/null", + "+++ b/empty-untracked.txt", + "", + ].join("\n")); + expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true }); + expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true }); + expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"])); + }, 20_000); + + it("returns head diffs against the requested base ref", async () => { + const repoRoot = await createTempRepo(); + await runGit(repoRoot, ["checkout", "-b", "feature"]); + await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8"); + await runGit(repoRoot, ["add", "tracked-staged.txt"]); + await runGit(repoRoot, ["commit", "-m", "Feature change"]); + + const diff = await workspaceDiffService().getDiff( + createWorkspace(repoRoot, { baseRef: "main" }), + workingTreeQuery({ view: "head", includeUntracked: false }), + ); + + expect(diff.baseRef).toBe("main"); + expect(diff.files).toHaveLength(1); + expect(diff.files[0]).toMatchObject({ + path: "tracked-staged.txt", + staged: false, + unstaged: false, + untracked: false, + additions: 1, + deletions: 0, + }); + expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]); + }, 20_000); + + it("filters changed files by relative workspace paths", async () => { + const repoRoot = await createTempRepo(); + await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8"); + await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8"); + + const diff = await workspaceDiffService().getDiff( + createWorkspace(repoRoot), + workingTreeQuery({ paths: ["tracked-staged.txt"] }), + ); + + expect(diff.paths).toEqual(["tracked-staged.txt"]); + expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]); + }, 20_000); + + it("applies output caps to large workspace responses", async () => { + const repoRoot = await createTempRepo(); + for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) { + await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8"); + } + + const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery()); + + expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles); + expect(diff.truncated).toBe(true); + expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" })); + }, 20_000); + + it("does not follow untracked symlinks outside the repo", async () => { + const repoRoot = await createTempRepo(); + const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-")); + tempDirs.add(outsideDir); + const secretContent = "external secret should not appear\n"; + const secretPath = path.join(outsideDir, "secret.txt"); + await fs.writeFile(secretPath, secretContent, "utf8"); + await fs.symlink(secretPath, path.join(repoRoot, "leak.txt")); + + const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery()); + const leak = diff.files.find((file) => file.path === "leak.txt"); + const serialized = JSON.stringify(diff); + + expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null }); + expect(leak?.patches[0]).toMatchObject({ + kind: "untracked", + patch: null, + warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })], + }); + expect(diff.warnings).toContainEqual(expect.objectContaining({ + code: "symlink_target_outside_workspace", + path: "leak.txt", + })); + expect(serialized).not.toContain(secretContent.trim()); + }, 20_000); + + it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => { + const svc = workspaceDiffService(); + await expect(svc.getDiff(createWorkspace(null), workingTreeQuery())) + .rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } }); + + const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-")); + tempDirs.add(nonGitDir); + await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery())) + .rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } }); + + const repoRoot = await createTempRepo(); + await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] }))) + .rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } }); + await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" }))) + .rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } }); + }, 20_000); +}); diff --git a/packages/plugins/plugin-workspace-diff/tsconfig.json b/packages/plugins/plugin-workspace-diff/tsconfig.json new file mode 100644 index 00000000..cf312210 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/plugins/plugin-workspace-diff/tsconfig.test.json b/packages/plugins/plugin-workspace-diff/tsconfig.test.json new file mode 100644 index 00000000..508201b3 --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"] +} diff --git a/packages/plugins/plugin-workspace-diff/vitest.config.ts b/packages/plugins/plugin-workspace-diff/vitest.config.ts new file mode 100644 index 00000000..649a293e --- /dev/null +++ b/packages/plugins/plugin-workspace-diff/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts b/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts index 02369921..79dd758c 100644 --- a/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts +++ b/packages/plugins/sandbox-providers/cloudflare/bridge-template/src/routes.ts @@ -423,6 +423,17 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { + // Heartbeat keeps the SSE response alive during silent stretches + // (e.g. npm install downloading silently). SSE comment lines (`:`) + // are ignored by the client parser but keep the underlying HTTP + // connection from idling out at the Cloudflare edge. + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": keepalive\n\n")); + } catch { + // Controller may already be closed; ignore. + } + }, 15_000); try { const result = await executeInSandbox({ sandbox, @@ -444,6 +455,7 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro error: error instanceof Error ? error.message : String(error), }))); } finally { + clearInterval(heartbeat); controller.close(); } }, diff --git a/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc b/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc index 24266c99..e306d229 100644 --- a/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc +++ b/packages/plugins/sandbox-providers/cloudflare/bridge-template/wrangler.jsonc @@ -7,7 +7,7 @@ { "class_name": "Sandbox", "image": "./Dockerfile", - "instance_type": "lite", + "instance_type": "standard-2", "max_instances": 10 } ], diff --git a/packages/plugins/sandbox-providers/cloudflare/src/config.ts b/packages/plugins/sandbox-providers/cloudflare/src/config.ts index 9aff3ac3..8bd6bab5 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/config.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/config.ts @@ -1,9 +1,9 @@ import type { CloudflareDriverConfig } from "./types.js"; const DEFAULT_REQUESTED_CWD = "/workspace/paperclip"; -const DEFAULT_SLEEP_AFTER = "10m"; +const DEFAULT_SLEEP_AFTER = "1h"; const DEFAULT_TIMEOUT_MS = 300_000; -const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000; const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); function readTrimmedString(value: unknown): string | null { diff --git a/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts b/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts index 21f3ddda..52b18660 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts @@ -49,8 +49,9 @@ const manifest: PaperclipPluginManifestV1 = { }, sleepAfter: { type: "string", - default: "10m", - description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.", + default: "1h", + description: + "Idle timeout passed to getSandbox() on lease creation. Defaults to 1 hour so a fresh sandbox survives normal Claude/Codex heartbeats. Ignored when keepAlive is true.", }, normalizeId: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts index 4452e97b..5e3908bd 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import plugin from "./plugin.js"; const fetchMock = vi.fn(); +let plugin: typeof import("./plugin.js").default; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -23,9 +23,11 @@ function requestBodyAt(index = 0): Record { } describe("Cloudflare sandbox provider plugin", () => { - beforeEach(() => { + beforeEach(async () => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); + vi.resetModules(); + plugin = (await import("./plugin.js")).default; }); it("declares the Cloudflare environment lifecycle handlers", async () => { @@ -60,7 +62,7 @@ describe("Cloudflare sandbox provider plugin", () => { bridgeAuthToken: "secret-ref://bridge-token", reuseLease: true, keepAlive: true, - sleepAfter: "10m", + sleepAfter: "1h", normalizeId: false, requestedCwd: "/workspace/custom", sessionStrategy: "default", @@ -143,6 +145,29 @@ describe("Cloudflare sandbox provider plugin", () => { }); }); + it("defaults the sleepAfter passed to the bridge to 1h so long runs don't idle out", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + providerLeaseId: "pc-run-1-abcd1234", + metadata: { provider: "cloudflare", remoteCwd: "/workspace/paperclip", resumedLease: false }, + }), + ); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "cloudflare", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + requestedCwd: "/workspace/paperclip", + config: { + bridgeBaseUrl: "https://bridge.example.workers.dev", + bridgeAuthToken: "resolved-token", + }, + }); + + expect(requestBodyAt()).toMatchObject({ sleepAfter: "1h" }); + }); + it("returns expired lease semantics when resume reports lost state", async () => { fetchMock.mockResolvedValueOnce( jsonResponse( @@ -210,6 +235,12 @@ describe("Cloudflare sandbox provider plugin", () => { }); it("routes bridge-channel execute calls through a dedicated session", async () => { + // pluginLogger must be set for the streaming branch to be reachable, so + // we can assert that bridge-channel calls take the non-streaming path + // even when adapter sessions would otherwise stream. + await plugin.definition.setup?.({ + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + } as never); fetchMock.mockResolvedValueOnce( jsonResponse({ exitCode: 0, @@ -248,6 +279,49 @@ describe("Cloudflare sandbox provider plugin", () => { }, }); expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL"); + // Bridge-channel commands must use the non-streaming exec path. The + // @cloudflare/sandbox SDK's streaming mode can drop the final stdout + // chunk when a short shell exits the same tick it writes — bridge ops + // carry machine-consumed stdout (readiness JSON, base64 file payloads, + // queue response bodies) where that data loss surfaces as opaque + // "invalid readiness JSON" / "Invalid bridge request payload" errors. + expect(requestBodyAt().streamOutput).toBe(false); + }); + + it("uses streaming exec for non-bridge adapter commands so live logs flow", async () => { + // Streaming is gated on `pluginLogger` being set, which normally happens + // in `setup()`. Wire a minimal logger so the streaming branch is reachable. + await plugin.definition.setup?.({ + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + } as never); + fetchMock.mockResolvedValueOnce( + new Response( + "event: stdout\ndata: {\"data\":\"hello\\n\"}\n\nevent: complete\ndata: {\"exitCode\":0,\"signal\":null,\"timedOut\":false,\"stdout\":\"hello\\n\",\"stderr\":\"\"}\n\n", + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ), + ); + + await plugin.definition.onEnvironmentExecute?.({ + driverKey: "cloudflare", + companyId: "company-1", + environmentId: "env-1", + lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} }, + command: "echo", + args: ["hello"], + cwd: "/workspace/paperclip", + env: { KEEP_ME: "visible" }, + config: { + bridgeBaseUrl: "https://bridge.example.workers.dev", + bridgeAuthToken: "resolved-token", + sessionStrategy: "named", + sessionId: "paperclip", + }, + }); + + expect(requestBodyAt().streamOutput).toBe(true); }); it("maps lost-lease execute errors into a deterministic command failure", async () => { diff --git a/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts b/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts index ad579a45..63a96dbe 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/plugin.ts @@ -317,7 +317,13 @@ const plugin = definePlugin({ const { config, client } = bridgeClientFor(params.config); const session = resolveExecuteSession(config, params.env); try { - const streamingOptions = pluginLogger + // Bridge-channel commands carry machine-consumed stdout (JSON, base64, + // file contents). The @cloudflare/sandbox SDK's streaming mode can drop + // the final stdout chunk when the inner shell exits the same tick as it + // writes (e.g. `cat ready.json && exit 0`), so we never stream for + // bridge control traffic — only adapter sessions get live log forwarding. + const isBridgeChannel = params.env?.[SANDBOX_EXEC_CHANNEL_ENV] === SANDBOX_EXEC_CHANNEL_BRIDGE; + const streamingOptions = pluginLogger && !isBridgeChannel ? { onOutput: async (stream: "stdout" | "stderr", chunk: string) => { logCloudflareExecChunk(pluginLogger, stream, chunk); diff --git a/packages/plugins/sandbox-providers/e2b/src/manifest.ts b/packages/plugins/sandbox-providers/e2b/src/manifest.ts index 11b23590..a245e80c 100644 --- a/packages/plugins/sandbox-providers/e2b/src/manifest.ts +++ b/packages/plugins/sandbox-providers/e2b/src/manifest.ts @@ -39,8 +39,9 @@ const manifest: PaperclipPluginManifestV1 = { }, timeoutMs: { type: "number", - description: "Sandbox timeout in milliseconds.", - default: 300000, + description: + "Sandbox lifetime in milliseconds, refreshed on each command. Defaults to 1 hour. Raise this if your runs commonly idle longer than the default between commands.", + default: 3600000, }, reuseLease: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts b/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts index 99b881c8..3f982284 100644 --- a/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts @@ -379,6 +379,59 @@ describe("E2B sandbox provider plugin", () => { }); }); + it("refreshes the sandbox lifetime on every execute so long runs don't die mid-command", async () => { + const sandbox = createMockSandbox(); + mockConnect.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentExecute?.({ + driverKey: "e2b", + companyId: "company-1", + environmentId: "env-1", + config: { + template: "base", + apiKey: "resolved-key", + timeoutMs: 1_800_000, + reuseLease: false, + }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "printf", + args: ["hello"], + cwd: "/workspace", + env: {}, + timeoutMs: 1000, + }); + + expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000); + }); + + it("still runs the command when the setTimeout refresh fails transiently", async () => { + const sandbox = createMockSandbox(); + sandbox.setTimeout.mockRejectedValueOnce(new Error("transient e2b api error")); + mockConnect.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "e2b", + companyId: "company-1", + environmentId: "env-1", + config: { + template: "base", + apiKey: "resolved-key", + timeoutMs: 1_800_000, + reuseLease: false, + }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "printf", + args: ["hello"], + cwd: "/workspace", + env: {}, + timeoutMs: 1000, + }); + + expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000); + expect(sandbox.commands.run).toHaveBeenCalled(); + expect(result?.exitCode).toBe(0); + }); + it("cleans up staged stdin even when writing it fails", async () => { const sandbox = createMockSandbox(); const failure = new Error("write failed"); diff --git a/packages/plugins/sandbox-providers/e2b/src/plugin.ts b/packages/plugins/sandbox-providers/e2b/src/plugin.ts index daf15486..e20dfd68 100644 --- a/packages/plugins/sandbox-providers/e2b/src/plugin.ts +++ b/packages/plugins/sandbox-providers/e2b/src/plugin.ts @@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record): E2bDriverConfig { const template = typeof raw.template === "string" && raw.template.trim().length > 0 ? raw.template.trim() : "base"; - const timeoutMs = Number(raw.timeoutMs ?? 300_000); + const timeoutMs = Number(raw.timeoutMs ?? 3_600_000); return { template, apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null, - timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000, + timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 3_600_000, reuseLease: raw.reuseLease === true, }; } @@ -391,6 +391,18 @@ const plugin = definePlugin({ const config = parseDriverConfig(params.config); const sandbox = await connectSandbox(config, params.lease.providerLeaseId); + // Refresh the sandbox death clock on every command. E2B's `timeoutMs` is + // the absolute sandbox lifetime from create/connect; without this, a run + // longer than `config.timeoutMs` will have its sandbox killed mid-command + // and the next call throws "Sandbox is probably not running anymore". + // The refresh is best-effort: the sandbox is already healthy at this + // point, so a transient API error on setTimeout should not block the + // command from running. Worst case the existing lifetime stands. + try { + await sandbox.setTimeout(config.timeoutMs); + } catch { + // ignore — keep going with the existing sandbox lifetime + } const baseCommand = buildLoginShellScript({ command: params.command, args: params.args ?? [], diff --git a/packages/plugins/sandbox-providers/modal/README.md b/packages/plugins/sandbox-providers/modal/README.md new file mode 100644 index 00000000..b3966004 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/README.md @@ -0,0 +1,77 @@ +# `@paperclipai/plugin-modal` + +First-party Modal sandbox provider plugin for Paperclip. + +Like the other sandbox-provider packages in this repo, it lives inside the Paperclip monorepo but is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That lets operators install it from the Plugins page by package name without introducing root lockfile churn for Modal's SDK dependencies. + +## Install + +From a Paperclip instance, install: + +```text +@paperclipai/plugin-modal +``` + +The host plugin installer runs `npm install` into the managed plugin directory, so the `modal` SDK dependency is pulled in during installation. + +## Runtime support note + +Modal's official JS SDK README pins support to **Node 22 or later**. Paperclip's repo baseline is currently `node >= 20`; empirically `modal@0.7.4` imports and operates against the Modal API under Node 20, so the plugin runs there today, but the vendor support contract is Node 22+. The plugin logs a startup warning when it detects Node `< 22`. Operators who can pin their Paperclip runtime to Node 22+ should do so; treat Node-20 usage as best-effort until the host bumps its baseline. + +The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues/PAPA-352). + +## Configuration + +Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page. + +| Field | Required | Description | +| --- | --- | --- | +| `appName` | yes | Modal App name. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist. | +| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`. | +| `tokenId` / `tokenSecret` | yes | Modal auth tokens. Both must be provided together. Paperclip stores pasted values as company secrets. The plugin worker runs in a child process that does not inherit host env vars, so `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` set on the Paperclip server are **not** read by the plugin — provide the tokens in this form. | +| `environment` | no | Optional Modal environment name. Falls back to the SDK profile default. | +| `workdir` | no | Remote working directory inside the sandbox. Defaults to `/workspace/paperclip`. | +| `sandboxTimeoutMs` | no | Maximum sandbox lifetime in milliseconds. Must be a positive multiple of `1000` between `1000` and `86_400_000` (24 hours). Defaults to `3_600_000` (1 hour). | +| `idleTimeoutMs` | no | Optional idle timeout in milliseconds. Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of `1000`. | +| `execTimeoutMs` | no | Default per-exec timeout in milliseconds when the caller does not pass one. Must be a positive multiple of `1000`. Defaults to `300_000` (5 minutes). | +| `blockNetwork` | no | Block all egress network access. | +| `cidrAllowlist` | no | List of CIDRs the sandbox may reach. Cannot be combined with `blockNetwork`. | +| `reuseLease` | no | When `true`, the sandbox is detached (not terminated) on release and reattached by id later. Defaults to `false`. | + +### Reuse semantics + +Modal does **not** expose a separate pause/resume primitive for sandboxes — there is no equivalent to e2b's `pause()`. The plugin implements `reuseLease` as follows: + +- **`reuseLease: false` (default)**: On release the sandbox is `terminate()`d. Subsequent runs create a new sandbox. +- **`reuseLease: true`**: On release the plugin calls `sandbox.detach()`. The sandbox keeps running on Modal until its configured `sandboxTimeoutMs` or `idleTimeoutMs` elapses. The next acquire/resume reconnects via `modal.sandboxes.fromId(providerLeaseId)`. If the sandbox has expired, `fromId` raises `NotFoundError` and the plugin reports the lease as expired so Paperclip reacquires. + +Because there is no real pause, **`reuseLease: true` keeps billing running** until the sandbox or idle timeout cuts it off. Tune `idleTimeoutMs` to a value that matches your reuse window. + +## Local development + +```bash +cd packages/plugins/sandbox-providers/modal +pnpm install --ignore-workspace --no-lockfile +pnpm build +pnpm test +pnpm typecheck +``` + +These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development. + +## Operator verification + +1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account. +2. Install the plugin from the Paperclip Plugins page. +3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`. +4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip. +5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up. + +Full end-to-end manual QA is tracked separately in [PAPA-354](/PAPA/issues/PAPA-354). + +## Package layout + +- `src/manifest.ts` declares the sandbox-provider driver metadata +- `src/plugin.ts` implements the environment lifecycle hooks +- `src/worker.ts` boots the plugin under the host worker runtime +- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/` diff --git a/packages/plugins/sandbox-providers/modal/package.json b/packages/plugins/sandbox-providers/modal/package.json new file mode 100644 index 00000000..e3d62639 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/package.json @@ -0,0 +1,61 @@ +{ + "name": "@paperclipai/plugin-modal", + "version": "0.1.0", + "description": "Modal sandbox provider plugin for Paperclip environments", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/plugins/sandbox-providers/modal" + }, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js" + }, + "keywords": [ + "paperclip", + "plugin", + "sandbox", + "modal" + ], + "scripts": { + "postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs", + "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "rm -rf dist && tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", + "test": "vitest run --config vitest.config.ts", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "dependencies": { + "modal": "^0.7.4" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/plugins/sandbox-providers/modal/src/index.ts b/packages/plugins/sandbox-providers/modal/src/index.ts new file mode 100644 index 00000000..f7ce1cc1 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as plugin } from "./plugin.js"; diff --git a/packages/plugins/sandbox-providers/modal/src/manifest.ts b/packages/plugins/sandbox-providers/modal/src/manifest.ts new file mode 100644 index 00000000..bd694622 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/src/manifest.ts @@ -0,0 +1,101 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const PLUGIN_ID = "paperclip.modal-sandbox-provider"; +const PLUGIN_VERSION = "0.1.0"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: PLUGIN_VERSION, + displayName: "Modal Sandbox Provider", + description: + "First-party sandbox provider plugin that provisions Modal sandboxes as Paperclip execution environments.", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { + worker: "./dist/worker.js", + }, + environmentDrivers: [ + { + driverKey: "modal", + kind: "sandbox_provider", + displayName: "Modal Sandbox", + description: + "Provisions Modal sandboxes with configurable image, app, auth, timeouts, and network controls.", + configSchema: { + type: "object", + required: ["appName", "image"], + properties: { + appName: { + type: "string", + description: + "Modal App name used as the parent for sandboxes. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist.", + }, + image: { + type: "string", + description: + "Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`.", + }, + tokenId: { + type: "string", + format: "secret-ref", + description: + "Modal token ID. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Required.", + }, + tokenSecret: { + type: "string", + format: "secret-ref", + description: "Modal token secret paired with tokenId. Required.", + }, + environment: { + type: "string", + description: + "Optional Modal environment name. Falls back to the SDK profile default.", + }, + workdir: { + type: "string", + description: "Remote working directory inside the sandbox.", + default: "/workspace/paperclip", + }, + sandboxTimeoutMs: { + type: "number", + description: + "Maximum sandbox lifetime in milliseconds. Must be a positive multiple of 1000 between 1000 and 86400000 (24 hours).", + default: 3_600_000, + }, + idleTimeoutMs: { + type: "number", + description: + "Optional idle timeout in milliseconds. When set, Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of 1000.", + }, + execTimeoutMs: { + type: "number", + description: + "Default per-exec timeout in milliseconds when the caller does not provide one. Must be a positive multiple of 1000.", + default: 300_000, + }, + blockNetwork: { + type: "boolean", + description: "Whether to block all egress network access from the sandbox.", + default: false, + }, + cidrAllowlist: { + type: "array", + items: { type: "string" }, + description: + "Optional list of CIDRs the sandbox is allowed to reach. Cannot be combined with blockNetwork.", + }, + reuseLease: { + type: "boolean", + description: + "When true, the sandbox is detached (not terminated) on release and resumed by id later. Reuse relies on Modal's sandbox lifetime and idle timeout because Modal has no separate pause primitive.", + default: false, + }, + }, + }, + }, + ], +}; + +export default manifest; diff --git a/packages/plugins/sandbox-providers/modal/src/plugin.test.ts b/packages/plugins/sandbox-providers/modal/src/plugin.test.ts new file mode 100644 index 00000000..b45536fa --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/src/plugin.test.ts @@ -0,0 +1,703 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError } = vi.hoisted(() => { + class MockNotFoundError extends Error {} + class MockTimeoutError extends Error {} + class MockSandboxTimeoutError extends Error {} + return { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError }; +}); + +const mockAppFromName = vi.hoisted(() => vi.fn()); +const mockImageFromRegistry = vi.hoisted(() => vi.fn(() => ({ kind: "image" }))); +const mockSandboxesCreate = vi.hoisted(() => vi.fn()); +const mockSandboxesFromId = vi.hoisted(() => vi.fn()); +const mockClientClose = vi.hoisted(() => vi.fn()); + +vi.mock("modal", () => ({ + ModalClient: class MockModalClient { + apps = { fromName: mockAppFromName }; + images = { fromRegistry: mockImageFromRegistry }; + sandboxes = { create: mockSandboxesCreate, fromId: mockSandboxesFromId }; + close = mockClientClose; + constructor(_params?: unknown) {} + }, + NotFoundError: MockNotFoundError, + TimeoutError: MockTimeoutError, + SandboxTimeoutError: MockSandboxTimeoutError, +})); + +import plugin from "./plugin.js"; + +interface FakeSandboxOverrides { + id?: string; + execImpl?: (argv: string[], params?: unknown) => Promise; +} + +interface FakeProcess { + stdout: { readText: () => Promise }; + stderr: { readText: () => Promise }; + wait: () => Promise; +} + +function makeFakeProcess(input: { + exitCode?: number; + stdout?: string; + stderr?: string; + throwOnWait?: unknown; +}): FakeProcess { + return { + stdout: { readText: vi.fn().mockResolvedValue(input.stdout ?? "") }, + stderr: { readText: vi.fn().mockResolvedValue(input.stderr ?? "") }, + wait: vi.fn().mockImplementation(async () => { + if (input.throwOnWait) throw input.throwOnWait; + return input.exitCode ?? 0; + }), + }; +} + +function createFakeSandbox(overrides: FakeSandboxOverrides = {}) { + const execCalls: Array<{ argv: string[]; params?: unknown }> = []; + const defaultExec = async (_argv: string[], _params?: unknown): Promise => + makeFakeProcess({ exitCode: 0, stdout: "paperclip-probe" }); + const exec = vi.fn().mockImplementation(async (argv: string[], params?: unknown) => { + execCalls.push({ argv, params }); + return overrides.execImpl ? overrides.execImpl(argv, params) : defaultExec(argv, params); + }); + const openedFiles: Array<{ path: string; mode: string; written: Uint8Array | null }> = []; + const sandbox = { + sandboxId: overrides.id ?? "sb-123", + exec, + execCalls, + openedFiles, + setTags: vi.fn().mockResolvedValue(undefined), + terminate: vi.fn().mockResolvedValue(undefined), + detach: vi.fn(), + poll: vi.fn().mockResolvedValue(null), + open: vi.fn().mockImplementation(async (path: string, mode: string) => { + const entry: { path: string; mode: string; written: Uint8Array | null } = { + path, + mode, + written: null, + }; + openedFiles.push(entry); + return { + write: vi.fn().mockImplementation(async (data: Uint8Array) => { + entry.written = data; + }), + flush: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + }), + }; + return sandbox; +} + +type FakeSandbox = ReturnType; + +const baseAcquireParams = { + driverKey: "modal", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", +}; + +const baseConfig = { + appName: "paperclip-app", + image: "node:20", + sandboxTimeoutMs: 3_600_000, + execTimeoutMs: 300_000, + reuseLease: false, +}; + +const baseConfigWithTokens = { + ...baseConfig, + tokenId: "config-id", + tokenSecret: "config-secret", +}; + +beforeEach(() => { + mockAppFromName.mockReset(); + mockImageFromRegistry.mockReset(); + mockImageFromRegistry.mockReturnValue({ kind: "image" }); + mockSandboxesCreate.mockReset(); + mockSandboxesFromId.mockReset(); + mockClientClose.mockReset(); + vi.restoreAllMocks(); + delete process.env.MODAL_TOKEN_ID; + delete process.env.MODAL_TOKEN_SECRET; +}); + +describe("Modal sandbox provider plugin", () => { + it("declares environment lifecycle handlers", async () => { + expect(await plugin.definition.onHealth?.()).toEqual({ + status: "ok", + message: "Modal sandbox provider plugin healthy", + }); + expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function"); + expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function"); + expect(plugin.definition.onEnvironmentReleaseLease).toBeTypeOf("function"); + expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function"); + }); + + it("normalizes config when both tokens are provided", async () => { + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "modal", + config: { + appName: " app-1 ", + image: " node:20 ", + tokenId: " token-id ", + tokenSecret: " token-secret ", + environment: " main ", + workdir: " /srv/work ", + sandboxTimeoutMs: "1800000", + idleTimeoutMs: "60000", + execTimeoutMs: "120000", + reuseLease: true, + blockNetwork: false, + cidrAllowlist: ["10.0.0.0/8"], + }, + }); + + expect(result).toEqual({ + ok: true, + normalizedConfig: { + appName: "app-1", + image: "node:20", + tokenId: "token-id", + tokenSecret: "token-secret", + environment: "main", + workdir: "/srv/work", + sandboxTimeoutMs: 1_800_000, + idleTimeoutMs: 60_000, + execTimeoutMs: 120_000, + blockNetwork: false, + cidrAllowlist: ["10.0.0.0/8"], + reuseLease: true, + }, + }); + }); + + it("ignores host MODAL_TOKEN_* env vars (plugin worker does not inherit them)", async () => { + process.env.MODAL_TOKEN_ID = "host-id"; + process.env.MODAL_TOKEN_SECRET = "host-secret"; + + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "modal", + config: { ...baseConfig }, + }); + + expect(result).toEqual({ + ok: false, + errors: ["Modal sandbox environments require tokenId and tokenSecret."], + }); + }); + + it("rejects invalid config", async () => { + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "modal", + config: { + appName: "", + image: "", + sandboxTimeoutMs: 1500, + idleTimeoutMs: 1500, + execTimeoutMs: 0, + blockNetwork: true, + cidrAllowlist: ["1.2.3.4/32"], + tokenId: "only-id", + }, + }); + + expect(result).toEqual({ + ok: false, + errors: [ + "Modal sandbox environments require an appName.", + "Modal sandbox environments require an image reference.", + "sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.", + "idleTimeoutMs must be a positive multiple of 1000 when provided.", + "execTimeoutMs must be a positive multiple of 1000.", + "cidrAllowlist cannot be combined with blockNetwork.", + "tokenId and tokenSecret must both be provided when either is set.", + ], + }); + }); + + it("requires both tokens in config", async () => { + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "modal", + config: { ...baseConfig }, + }); + expect(result).toEqual({ + ok: false, + errors: ["Modal sandbox environments require tokenId and tokenSecret."], + }); + }); + + it("probes by creating, executing, and terminating a sandbox", async () => { + const sandbox = createFakeSandbox(); + mockAppFromName.mockResolvedValue({ appId: "ap-1" }); + mockSandboxesCreate.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentProbe?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: { ...baseConfig, workdir: "/srv/work" }, + }); + + expect(mockAppFromName).toHaveBeenCalledWith("paperclip-app", { + createIfMissing: true, + environment: undefined, + }); + expect(mockImageFromRegistry).toHaveBeenCalledWith("node:20"); + expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({ + "paperclip-provider": "modal", + "paperclip-company-id": "c-1", + })); + // First exec is the mkdir for the workspace, second is the probe command. + expect(sandbox.execCalls[0]?.argv).toEqual([ + "sh", + "-lc", + "mkdir -p '/srv/work'", + ]); + expect(sandbox.execCalls[1]?.argv).toEqual([ + "sh", + "-lc", + "printf paperclip-probe", + ]); + expect(sandbox.terminate).toHaveBeenCalled(); + expect(mockClientClose).toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: true, + metadata: { + provider: "modal", + sandboxId: "sb-123", + remoteCwd: "/srv/work", + reuseLease: false, + }, + }); + }); + + it("returns a failure probe result when the probe command exits non-zero", async () => { + const sandbox = createFakeSandbox({ + execImpl: async (argv: string[]) => { + if (argv[2] === "printf paperclip-probe") { + return makeFakeProcess({ exitCode: 7, stdout: "boom" }); + } + return makeFakeProcess({ exitCode: 0 }); + }, + }); + mockAppFromName.mockResolvedValue({ appId: "ap-1" }); + mockSandboxesCreate.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentProbe?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + }); + + expect(result?.ok).toBe(false); + expect(sandbox.terminate).toHaveBeenCalled(); + }); + + it("closes the Modal client when probe fails before sandbox creation", async () => { + mockAppFromName.mockRejectedValue(new Error("app lookup failed")); + + const result = await plugin.definition.onEnvironmentProbe?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + }); + + expect(result).toMatchObject({ + ok: false, + summary: "Modal sandbox probe failed.", + metadata: expect.objectContaining({ + error: "app lookup failed", + }), + }); + expect(mockClientClose).toHaveBeenCalledTimes(1); + }); + + it("acquires a lease, applies tags, and ensures the workspace directory", async () => { + const sandbox = createFakeSandbox({ id: "sb-acquire" }); + mockAppFromName.mockResolvedValue({ appId: "ap-1" }); + mockSandboxesCreate.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.({ + ...baseAcquireParams, + config: { ...baseConfig, reuseLease: true, workdir: "/srv/work" }, + }); + + expect(lease).toEqual({ + providerLeaseId: "sb-acquire", + metadata: expect.objectContaining({ + provider: "modal", + sandboxId: "sb-acquire", + remoteCwd: "/srv/work", + reuseLease: true, + resumedLease: false, + }), + }); + expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({ + "paperclip-run-id": "run-1", + "paperclip-reuse-lease": "true", + })); + expect(sandbox.execCalls[0]?.argv).toEqual(["sh", "-lc", "mkdir -p '/srv/work'"]); + }); + + it("terminates the sandbox if acquire workspace setup throws", async () => { + const sandbox = createFakeSandbox({ + execImpl: async (argv: string[]) => { + if (argv[2]?.startsWith("mkdir -p")) { + return makeFakeProcess({ throwOnWait: new Error("mkdir failed") }); + } + return makeFakeProcess({ exitCode: 0 }); + }, + }); + mockAppFromName.mockResolvedValue({ appId: "ap-1" }); + mockSandboxesCreate.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentAcquireLease?.({ + ...baseAcquireParams, + config: baseConfig, + }), + ).rejects.toThrow("mkdir failed"); + expect(sandbox.terminate).toHaveBeenCalledTimes(1); + }); + + it("fails acquire when workspace creation exits non-zero", async () => { + const sandbox = createFakeSandbox({ + execImpl: async (argv: string[]) => { + if (argv[2]?.startsWith("mkdir -p")) { + return makeFakeProcess({ exitCode: 17 }); + } + return makeFakeProcess({ exitCode: 0 }); + }, + }); + mockAppFromName.mockResolvedValue({ appId: "ap-1" }); + mockSandboxesCreate.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentAcquireLease?.({ + ...baseAcquireParams, + config: baseConfig, + }), + ).rejects.toThrow( + "Failed to create remote workspace directory '/workspace/paperclip': mkdir exited with code 17", + ); + expect(sandbox.terminate).toHaveBeenCalledTimes(1); + }); + + it("closes the Modal client when acquire fails before sandbox creation", async () => { + mockAppFromName.mockRejectedValue(new Error("app lookup failed")); + + await expect( + plugin.definition.onEnvironmentAcquireLease?.({ + ...baseAcquireParams, + config: baseConfig, + }), + ).rejects.toThrow("app lookup failed"); + expect(mockClientClose).toHaveBeenCalledTimes(1); + }); + + it("treats missing leases as expired on resume", async () => { + mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone")); + + const lease = await plugin.definition.onEnvironmentResumeLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-missing", + config: { ...baseConfig, reuseLease: true }, + }); + expect(lease).toEqual({ providerLeaseId: null, metadata: { expired: true } }); + }); + + it("resumes a reusable lease by reconnecting via fromId", async () => { + const sandbox = createFakeSandbox({ id: "sb-resume" }); + mockSandboxesFromId.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentResumeLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-resume", + config: { ...baseConfig, reuseLease: true }, + }); + + expect(lease).toEqual({ + providerLeaseId: "sb-resume", + metadata: expect.objectContaining({ + provider: "modal", + sandboxId: "sb-resume", + resumedLease: true, + reuseLease: true, + }), + }); + }); + + it("detaches the sandbox if resumed workspace setup fails", async () => { + const sandbox = createFakeSandbox({ + id: "sb-resume", + execImpl: async (argv: string[]) => { + if (argv[2]?.startsWith("mkdir -p")) { + return makeFakeProcess({ throwOnWait: new Error("mkdir failed") }); + } + return makeFakeProcess({ exitCode: 0 }); + }, + }); + mockSandboxesFromId.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentResumeLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-resume", + config: { ...baseConfig, reuseLease: true }, + }), + ).rejects.toThrow("mkdir failed"); + expect(sandbox.detach).toHaveBeenCalledTimes(1); + }); + + it("detaches reusable leases and terminates ephemeral leases on release", async () => { + const reusable = createFakeSandbox({ id: "sb-reuse" }); + const ephemeral = createFakeSandbox({ id: "sb-ephem" }); + mockSandboxesFromId.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral); + + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-reuse", + config: { ...baseConfig, reuseLease: true }, + }); + await plugin.definition.onEnvironmentReleaseLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-ephem", + config: { ...baseConfig, reuseLease: false }, + }); + + expect(reusable.detach).toHaveBeenCalled(); + expect(reusable.terminate).not.toHaveBeenCalled(); + expect(ephemeral.terminate).toHaveBeenCalled(); + expect(ephemeral.detach).not.toHaveBeenCalled(); + }); + + it("destroys leases by terminating, ignoring missing sandboxes", async () => { + const sandbox = createFakeSandbox({ id: "sb-destroy" }); + mockSandboxesFromId.mockResolvedValueOnce(sandbox); + + await plugin.definition.onEnvironmentDestroyLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-destroy", + config: baseConfig, + }); + expect(sandbox.terminate).toHaveBeenCalled(); + + mockSandboxesFromId.mockRejectedValueOnce(new MockNotFoundError("missing")); + await expect( + plugin.definition.onEnvironmentDestroyLease?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + providerLeaseId: "sb-missing", + config: baseConfig, + }), + ).resolves.toBeUndefined(); + }); + + it("realizes the workspace using the lease metadata cwd when available", async () => { + const sandbox = createFakeSandbox({ id: "sb-real" }); + mockSandboxesFromId.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { + providerLeaseId: "sb-real", + metadata: { remoteCwd: "/srv/from-metadata" }, + }, + workspace: { localPath: "/local", remotePath: "/remote" }, + }); + + expect(sandbox.execCalls[0]?.argv).toEqual([ + "sh", + "-lc", + "mkdir -p '/srv/from-metadata'", + ]); + expect(result).toEqual({ + cwd: "/srv/from-metadata", + metadata: { provider: "modal", remoteCwd: "/srv/from-metadata" }, + }); + }); + + it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => { + const sandbox = createFakeSandbox({ + execImpl: async (argv: string[]) => + makeFakeProcess({ + exitCode: 5, + stdout: "stdout-output", + stderr: "stderr-output", + }), + }); + mockSandboxesFromId.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: "sb-exec", metadata: {} }, + command: "printf", + args: ["hello"], + cwd: "/srv/work", + env: { FOO: "bar" }, + timeoutMs: 12_000, + }); + + expect(sandbox.execCalls).toHaveLength(1); + const call = sandbox.execCalls[0]!; + expect(call.argv[0]).toBe("sh"); + expect(call.argv[1]).toBe("-lc"); + const script = call.argv[2]!; + expect(script).toMatch(/\/etc\/profile/); + expect(script).toMatch(/cd '\/srv\/work'/); + expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/); + expect(call.params).toMatchObject({ + timeoutMs: 12_000, + stdout: "pipe", + stderr: "pipe", + }); + expect(result).toEqual({ + exitCode: 5, + timedOut: false, + stdout: "stdout-output", + stderr: "stderr-output", + }); + }); + + it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => { + const sandbox = createFakeSandbox(); + mockSandboxesFromId.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: "sb-exec", metadata: {} }, + command: "cat", + args: [], + stdin: "input payload", + cwd: "/srv/work", + }); + + expect(sandbox.openedFiles).toHaveLength(1); + expect(sandbox.openedFiles[0]?.path).toMatch(/^\/tmp\/paperclip-stdin-/); + expect(sandbox.openedFiles[0]?.mode).toBe("w"); + expect(sandbox.openedFiles[0]?.written).not.toBeNull(); + expect(new TextDecoder().decode(sandbox.openedFiles[0]!.written!)).toBe("input payload"); + + // First exec is the user command; second is the rm cleanup. + const userCall = sandbox.execCalls[0]!; + expect(userCall.argv[2]).toMatch(/&& exec 'cat' < '\/tmp\/paperclip-stdin-/); + const cleanupCall = sandbox.execCalls[1]!; + expect(cleanupCall.argv[2]).toMatch(/^rm -f '\/tmp\/paperclip-stdin-/); + expect(result?.exitCode).toBe(0); + }); + + it("rejects invalid shell env keys before execution", async () => { + const sandbox = createFakeSandbox(); + mockSandboxesFromId.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: "sb-exec", metadata: {} }, + command: "printf", + args: ["hello"], + env: { "BAD-KEY": "v" }, + }), + ).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY"); + expect(sandbox.execCalls).toHaveLength(0); + }); + + it("returns an error result when execute is called for an expired sandbox lease", async () => { + mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone")); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: "sb-expired", metadata: {} }, + command: "printf", + args: ["hello"], + }); + + expect(result).toEqual({ + exitCode: 1, + timedOut: false, + stdout: "", + stderr: "Modal sandbox lease is no longer available.\n", + }); + }); + + it("returns a timedOut result when Modal raises a TimeoutError during exec", async () => { + const sandbox = createFakeSandbox({ + execImpl: async () => + makeFakeProcess({ throwOnWait: new MockTimeoutError("exec timed out") }), + }); + mockSandboxesFromId.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: "sb-exec", metadata: {} }, + command: "sleep", + args: ["60"], + cwd: "/srv/work", + timeoutMs: 5_000, + }); + + expect(result).toEqual({ + exitCode: null, + timedOut: true, + stdout: "", + stderr: "exec timed out\n", + }); + }); + + it("returns an error result when execute is called without a provider lease id", async () => { + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "modal", + companyId: "c-1", + environmentId: "e-1", + config: baseConfig, + lease: { providerLeaseId: null, metadata: {} }, + command: "printf", + args: ["hello"], + }); + expect(result).toEqual({ + exitCode: 1, + timedOut: false, + stdout: "", + stderr: "No provider lease ID available for execution.", + }); + }); +}); diff --git a/packages/plugins/sandbox-providers/modal/src/plugin.ts b/packages/plugins/sandbox-providers/modal/src/plugin.ts new file mode 100644 index 00000000..2a3fbffb --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/src/plugin.ts @@ -0,0 +1,660 @@ +import { + ModalClient, + NotFoundError, + SandboxTimeoutError, + TimeoutError, + type App, + type ContainerProcess, + type Sandbox, + type SandboxCreateParams, +} from "modal"; +import { definePlugin } from "@paperclipai/plugin-sdk"; +import type { + PluginEnvironmentAcquireLeaseParams, + PluginEnvironmentDestroyLeaseParams, + PluginEnvironmentExecuteParams, + PluginEnvironmentExecuteResult, + PluginEnvironmentLease, + PluginEnvironmentProbeParams, + PluginEnvironmentProbeResult, + PluginEnvironmentRealizeWorkspaceParams, + PluginEnvironmentRealizeWorkspaceResult, + PluginEnvironmentReleaseLeaseParams, + PluginEnvironmentResumeLeaseParams, + PluginEnvironmentValidateConfigParams, + PluginEnvironmentValidationResult, +} from "@paperclipai/plugin-sdk"; + +const DEFAULT_WORKDIR = "/workspace/paperclip"; +const DEFAULT_SANDBOX_TIMEOUT_MS = 3_600_000; +const DEFAULT_EXEC_TIMEOUT_MS = 300_000; +const MAX_SANDBOX_TIMEOUT_MS = 86_400_000; + +interface ModalDriverConfig { + appName: string; + image: string; + tokenId: string | null; + tokenSecret: string | null; + environment: string | null; + workdir: string; + sandboxTimeoutMs: number; + idleTimeoutMs: number | null; + execTimeoutMs: number; + blockNetwork: boolean; + cidrAllowlist: string[] | null; + reuseLease: boolean; +} + +function parseOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseOptionalNumber(value: unknown): number | null { + if (value == null || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const trimmed = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return trimmed.length > 0 ? trimmed : null; +} + +export function parseDriverConfig(raw: Record): ModalDriverConfig { + const sandboxTimeoutMsRaw = parseOptionalNumber(raw.sandboxTimeoutMs); + const execTimeoutMsRaw = parseOptionalNumber(raw.execTimeoutMs); + const idleTimeoutMsRaw = parseOptionalNumber(raw.idleTimeoutMs); + return { + appName: parseOptionalString(raw.appName) ?? "", + image: parseOptionalString(raw.image) ?? "", + tokenId: parseOptionalString(raw.tokenId), + tokenSecret: parseOptionalString(raw.tokenSecret), + environment: parseOptionalString(raw.environment), + workdir: parseOptionalString(raw.workdir) ?? DEFAULT_WORKDIR, + sandboxTimeoutMs: + sandboxTimeoutMsRaw != null ? Math.trunc(sandboxTimeoutMsRaw) : DEFAULT_SANDBOX_TIMEOUT_MS, + idleTimeoutMs: idleTimeoutMsRaw != null ? Math.trunc(idleTimeoutMsRaw) : null, + execTimeoutMs: + execTimeoutMsRaw != null ? Math.trunc(execTimeoutMsRaw) : DEFAULT_EXEC_TIMEOUT_MS, + blockNetwork: raw.blockNetwork === true, + cidrAllowlist: parseStringArray(raw.cidrAllowlist), + reuseLease: raw.reuseLease === true, + }; +} + +function isMultipleOf1000(value: number): boolean { + return value > 0 && value % 1000 === 0; +} + +function resolveAuth(config: ModalDriverConfig): { tokenId: string; tokenSecret: string } | null { + // The plugin worker runs in a child process that does not inherit host env + // vars (see PluginWorkerManager.spawnProcess), so MODAL_TOKEN_ID / + // MODAL_TOKEN_SECRET cannot be read here. Credentials must come from the + // environment config, which Paperclip stores as company secrets. + const tokenId = config.tokenId ?? ""; + const tokenSecret = config.tokenSecret ?? ""; + if (!tokenId && !tokenSecret) return null; + if (!tokenId || !tokenSecret) { + throw new Error("Modal sandbox environments require both tokenId and tokenSecret to be configured."); + } + return { tokenId, tokenSecret }; +} + +function createModalClient(config: ModalDriverConfig): ModalClient { + const auth = resolveAuth(config); + const params: ConstructorParameters[0] = {}; + if (auth) { + params.tokenId = auth.tokenId; + params.tokenSecret = auth.tokenSecret; + } + if (config.environment) { + params.environment = config.environment; + } + return new ModalClient(params); +} + +async function resolveApp(client: ModalClient, config: ModalDriverConfig): Promise { + return await client.apps.fromName(config.appName, { + createIfMissing: true, + environment: config.environment ?? undefined, + }); +} + +function buildSandboxCreateParams(input: { + config: ModalDriverConfig; + tags: Record; +}): SandboxCreateParams { + const params: SandboxCreateParams = { + workdir: input.config.workdir, + timeoutMs: input.config.sandboxTimeoutMs, + blockNetwork: input.config.blockNetwork, + }; + if (input.config.idleTimeoutMs != null) { + params.idleTimeoutMs = input.config.idleTimeoutMs; + } + if (input.config.cidrAllowlist && input.config.cidrAllowlist.length > 0) { + params.cidrAllowlist = input.config.cidrAllowlist; + } + // Modal sandboxes accept tag metadata via setTags after creation; the create + // RPC does not take tags directly. We pass them through input so the caller + // can apply them after `create` resolves. + void input.tags; + return params; +} + +function buildSandboxTags(input: { + companyId: string; + environmentId: string; + runId?: string; + reuseLease: boolean; +}): Record { + return { + "paperclip-provider": "modal", + "paperclip-company-id": input.companyId, + "paperclip-environment-id": input.environmentId, + "paperclip-reuse-lease": input.reuseLease ? "true" : "false", + ...(input.runId ? { "paperclip-run-id": input.runId } : {}), + }; +} + +async function createSandboxFor( + client: ModalClient, + app: App, + config: ModalDriverConfig, + tags: Record, +): Promise { + const image = client.images.fromRegistry(config.image); + const params = buildSandboxCreateParams({ config, tags }); + const sandbox = await client.sandboxes.create(app, image, params); + try { + await sandbox.setTags(tags); + } catch (error) { + // setTags is best-effort metadata; surface but do not block lease creation. + console.warn(`Failed to set tags on Modal sandbox ${sandbox.sandboxId}: ${formatErrorMessage(error)}`); + } + return sandbox; +} + +function leaseMetadata(input: { + config: ModalDriverConfig; + sandbox: Sandbox; + remoteCwd: string; + resumedLease: boolean; +}) { + return { + provider: "modal", + shellCommand: "sh", + sandboxId: input.sandbox.sandboxId, + appName: input.config.appName, + image: input.config.image, + sandboxTimeoutMs: input.config.sandboxTimeoutMs, + idleTimeoutMs: input.config.idleTimeoutMs, + reuseLease: input.config.reuseLease, + remoteCwd: input.remoteCwd, + resumedLease: input.resumedLease, + }; +} + +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function isValidShellEnvKey(value: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely, +// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc +// do not work without an explicit login shell. Mirroring the Daytona / E2B +// providers, wrap the user command in a `sh -lc` script that sources common +// login profiles plus nvm before invoking it. Env is set after profile sourcing +// so caller env wins; stdin is staged to a temp file and shell-redirected so +// fast-failing commands do not race a streaming stdin writer. +function buildLoginShellScript(input: { + command: string; + args: string[]; + cwd?: string; + env?: Record; + stdinPath?: string; +}): string { + const env = input.env ?? {}; + for (const key of Object.keys(env)) { + if (!isValidShellEnvKey(key)) { + throw new Error(`Invalid sandbox environment variable key: ${key}`); + } + } + const envArgs = Object.entries(env) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(([key, value]) => `${key}=${shellQuote(value)}`); + const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" "); + const redirected = input.stdinPath + ? `${commandParts} < ${shellQuote(input.stdinPath)}` + : commandParts; + const finalLine = envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${redirected}` : `exec ${redirected}`; + const lines = [ + 'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi', + 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"', + '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true', + ]; + if (input.cwd) { + lines.push(`cd ${shellQuote(input.cwd)}`); + } + lines.push(finalLine); + return lines.join(" && "); +} + +async function ensureRemoteWorkspace(sandbox: Sandbox, remoteCwd: string): Promise { + // Use a one-shot exec to mkdir -p; Modal does not expose a direct + // filesystem mkdir helper and creating a file via `open()` does not create + // intermediate directories. + const proc = await sandbox.exec(["sh", "-lc", `mkdir -p ${shellQuote(remoteCwd)}`]); + const exitCode = await proc.wait(); + if (exitCode !== 0) { + throw new Error( + `Failed to create remote workspace directory '${remoteCwd}': mkdir exited with code ${exitCode}`, + ); + } +} + +async function stageStdin(sandbox: Sandbox, stdin: string, remotePath: string): Promise { + const file = await sandbox.open(remotePath, "w"); + try { + await file.write(new TextEncoder().encode(stdin)); + await file.flush(); + } finally { + await file.close().catch(() => undefined); + } +} + +async function deleteStdinPath(sandbox: Sandbox, remotePath: string): Promise { + // Best-effort cleanup of the staged stdin file. We swallow errors because + // it is fine for the file to outlive the sandbox if it is going to be + // terminated, and a missing rm tool would otherwise mask the real result. + try { + const proc = await sandbox.exec(["sh", "-lc", `rm -f ${shellQuote(remotePath)}`]); + await proc.wait(); + } catch { + // ignore + } +} + +async function readProcessStreams( + proc: ContainerProcess, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.readText(), + proc.stderr.readText(), + proc.wait(), + ]); + return { stdout, stderr, exitCode }; +} + +function isModalNotFound(error: unknown): boolean { + return error instanceof NotFoundError; +} + +async function getSandboxOrNull( + client: ModalClient, + providerLeaseId: string, +): Promise { + try { + return await client.sandboxes.fromId(providerLeaseId); + } catch (error) { + if (isModalNotFound(error)) return null; + throw error; + } +} + +function warnIfUnsupportedNode(logger: { warn: (msg: string) => void } | undefined): void { + const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); + if (Number.isFinite(major) && major < 22) { + const message = `Modal sandbox provider is running on Node ${process.versions.node}; Modal officially supports Node 22+. The plugin will attempt to operate but vendor support is not guaranteed below Node 22.`; + logger?.warn(message); + } +} + +function leaseRemoteCwd(metadata: Record | undefined, fallback: string): string { + if (metadata && typeof metadata.remoteCwd === "string" && metadata.remoteCwd.trim().length > 0) { + return metadata.remoteCwd.trim(); + } + return fallback; +} + +const plugin = definePlugin({ + async setup(ctx) { + warnIfUnsupportedNode(ctx.logger); + ctx.logger.info("Modal sandbox provider plugin ready"); + }, + + async onHealth() { + return { status: "ok", message: "Modal sandbox provider plugin healthy" }; + }, + + async onEnvironmentValidateConfig( + params: PluginEnvironmentValidateConfigParams, + ): Promise { + const config = parseDriverConfig(params.config); + const errors: string[] = []; + + if (!config.appName) { + errors.push("Modal sandbox environments require an appName."); + } + if (!config.image) { + errors.push("Modal sandbox environments require an image reference."); + } + if ( + config.sandboxTimeoutMs < 1000 || + config.sandboxTimeoutMs > MAX_SANDBOX_TIMEOUT_MS || + !isMultipleOf1000(config.sandboxTimeoutMs) + ) { + errors.push( + "sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.", + ); + } + if ( + config.idleTimeoutMs != null && + (config.idleTimeoutMs < 1000 || !isMultipleOf1000(config.idleTimeoutMs)) + ) { + errors.push("idleTimeoutMs must be a positive multiple of 1000 when provided."); + } + if (config.execTimeoutMs < 1000 || !isMultipleOf1000(config.execTimeoutMs)) { + errors.push("execTimeoutMs must be a positive multiple of 1000."); + } + if (config.blockNetwork && config.cidrAllowlist && config.cidrAllowlist.length > 0) { + errors.push("cidrAllowlist cannot be combined with blockNetwork."); + } + const hasTokenId = Boolean(config.tokenId); + const hasTokenSecret = Boolean(config.tokenSecret); + if (hasTokenId !== hasTokenSecret) { + errors.push("tokenId and tokenSecret must both be provided when either is set."); + } else if (!hasTokenId) { + errors.push("Modal sandbox environments require tokenId and tokenSecret."); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + return { ok: true, normalizedConfig: { ...config } }; + }, + + async onEnvironmentProbe( + params: PluginEnvironmentProbeParams, + ): Promise { + const config = parseDriverConfig(params.config); + const tags = buildSandboxTags({ + companyId: params.companyId, + environmentId: params.environmentId, + reuseLease: false, + }); + const client = createModalClient(config); + try { + const app = await resolveApp(client, config); + const sandbox = await createSandboxFor(client, app, config, tags); + try { + await ensureRemoteWorkspace(sandbox, config.workdir); + const proc = await sandbox.exec(["sh", "-lc", "printf paperclip-probe"]); + const { stdout, exitCode } = await readProcessStreams(proc); + if (exitCode !== 0 || stdout.trim() !== "paperclip-probe") { + return { + ok: false, + summary: `Modal sandbox probe failed: exit ${exitCode}, stdout=${JSON.stringify(stdout)}`, + metadata: { + provider: "modal", + sandboxId: sandbox.sandboxId, + appName: config.appName, + image: config.image, + }, + }; + } + return { + ok: true, + summary: `Connected to Modal sandbox in app ${config.appName}.`, + metadata: { + provider: "modal", + sandboxId: sandbox.sandboxId, + appName: config.appName, + image: config.image, + workdir: config.workdir, + sandboxTimeoutMs: config.sandboxTimeoutMs, + idleTimeoutMs: config.idleTimeoutMs, + reuseLease: config.reuseLease, + remoteCwd: config.workdir, + }, + }; + } finally { + await sandbox.terminate().catch(() => undefined); + } + } catch (error) { + return { + ok: false, + summary: "Modal sandbox probe failed.", + metadata: { + provider: "modal", + appName: config.appName, + image: config.image, + reuseLease: config.reuseLease, + error: formatErrorMessage(error), + }, + }; + } finally { + client.close(); + } + }, + + async onEnvironmentAcquireLease( + params: PluginEnvironmentAcquireLeaseParams, + ): Promise { + const config = parseDriverConfig(params.config); + const client = createModalClient(config); + try { + const app = await resolveApp(client, config); + const tags = buildSandboxTags({ + companyId: params.companyId, + environmentId: params.environmentId, + runId: params.runId, + reuseLease: config.reuseLease, + }); + const sandbox = await createSandboxFor(client, app, config, tags); + try { + await ensureRemoteWorkspace(sandbox, config.workdir); + return { + providerLeaseId: sandbox.sandboxId, + metadata: leaseMetadata({ + config, + sandbox, + remoteCwd: config.workdir, + resumedLease: false, + }), + }; + } catch (error) { + await sandbox.terminate().catch(() => undefined); + throw error; + } + } finally { + // Keep the client open for the lease lifetime is unnecessary; subsequent + // calls construct their own client. Close the local handle to free + // grpc resources. + client.close(); + } + }, + + async onEnvironmentResumeLease( + params: PluginEnvironmentResumeLeaseParams, + ): Promise { + const config = parseDriverConfig(params.config); + const client = createModalClient(config); + try { + const sandbox = await getSandboxOrNull(client, params.providerLeaseId); + if (!sandbox) { + return { providerLeaseId: null, metadata: { expired: true } }; + } + try { + await ensureRemoteWorkspace(sandbox, config.workdir); + return { + providerLeaseId: sandbox.sandboxId, + metadata: leaseMetadata({ config, sandbox, remoteCwd: config.workdir, resumedLease: true }), + }; + } catch (error) { + // If we just resumed and workspace setup blew up, treat as a lease + // failure rather than silently terminating the user's reusable + // sandbox. Detach so the sandbox is not killed for a transient setup + // error. + void sandbox.detach(); + throw error; + } + } finally { + client.close(); + } + }, + + async onEnvironmentReleaseLease( + params: PluginEnvironmentReleaseLeaseParams, + ): Promise { + if (!params.providerLeaseId) return; + const config = parseDriverConfig(params.config); + const client = createModalClient(config); + try { + const sandbox = await getSandboxOrNull(client, params.providerLeaseId); + if (!sandbox) return; + if (config.reuseLease) { + // Modal has no separate pause primitive. Detaching releases the local + // grpc connection but leaves the sandbox running on Modal until its + // configured sandboxTimeoutMs or idleTimeoutMs expires. The next + // acquire/resume reconnects via sandboxes.fromId(providerLeaseId). + void sandbox.detach(); + return; + } + await sandbox.terminate(); + } finally { + client.close(); + } + }, + + async onEnvironmentDestroyLease( + params: PluginEnvironmentDestroyLeaseParams, + ): Promise { + if (!params.providerLeaseId) return; + const config = parseDriverConfig(params.config); + const client = createModalClient(config); + try { + const sandbox = await getSandboxOrNull(client, params.providerLeaseId); + if (!sandbox) return; + await sandbox.terminate(); + } finally { + client.close(); + } + }, + + async onEnvironmentRealizeWorkspace( + params: PluginEnvironmentRealizeWorkspaceParams, + ): Promise { + const config = parseDriverConfig(params.config); + const fallback = + params.workspace.remotePath ?? + params.workspace.localPath ?? + config.workdir; + const remoteCwd = leaseRemoteCwd(params.lease.metadata, fallback); + if (params.lease.providerLeaseId) { + const client = createModalClient(config); + try { + const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId); + if (sandbox) { + await ensureRemoteWorkspace(sandbox, remoteCwd); + } + } finally { + client.close(); + } + } + return { + cwd: remoteCwd, + metadata: { provider: "modal", remoteCwd }, + }; + }, + + async onEnvironmentExecute( + params: PluginEnvironmentExecuteParams, + ): Promise { + if (!params.lease.providerLeaseId) { + return { + exitCode: 1, + timedOut: false, + stdout: "", + stderr: "No provider lease ID available for execution.", + }; + } + const config = parseDriverConfig(params.config); + const client = createModalClient(config); + const callerTimeoutMs = + params.timeoutMs != null && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0 + ? Math.max(1000, Math.trunc(params.timeoutMs / 1000) * 1000) + : config.execTimeoutMs; + + try { + const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId); + if (!sandbox) { + return { + exitCode: 1, + timedOut: false, + stdout: "", + stderr: "Modal sandbox lease is no longer available.\n", + }; + } + const stdinPath = params.stdin != null + ? `/tmp/paperclip-stdin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + : null; + try { + if (stdinPath && params.stdin != null) { + await stageStdin(sandbox, params.stdin, stdinPath); + } + const script = buildLoginShellScript({ + command: params.command, + args: params.args ?? [], + cwd: params.cwd ?? config.workdir, + env: params.env, + stdinPath: stdinPath ?? undefined, + }); + const proc = await sandbox.exec(["sh", "-lc", script], { + timeoutMs: callerTimeoutMs, + stdout: "pipe", + stderr: "pipe", + }); + const { stdout, stderr, exitCode } = await readProcessStreams(proc); + return { + exitCode, + timedOut: false, + stdout, + stderr, + }; + } catch (error) { + if (error instanceof TimeoutError || error instanceof SandboxTimeoutError) { + return { + exitCode: null, + timedOut: true, + stdout: "", + stderr: `${formatErrorMessage(error)}\n`, + }; + } + throw error; + } finally { + if (stdinPath) { + await deleteStdinPath(sandbox, stdinPath); + } + } + } finally { + client.close(); + } + }, +}); + +export default plugin; diff --git a/packages/plugins/sandbox-providers/modal/src/worker.ts b/packages/plugins/sandbox-providers/modal/src/worker.ts new file mode 100644 index 00000000..1e156024 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/src/worker.ts @@ -0,0 +1,5 @@ +import { runWorker } from "@paperclipai/plugin-sdk"; +import plugin from "./plugin.js"; + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/sandbox-providers/modal/tsconfig.json b/packages/plugins/sandbox-providers/modal/tsconfig.json new file mode 100644 index 00000000..000e3293 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugins/sandbox-providers/modal/vitest.config.ts b/packages/plugins/sandbox-providers/modal/vitest.config.ts new file mode 100644 index 00000000..ce36a742 --- /dev/null +++ b/packages/plugins/sandbox-providers/modal/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index c1f73d98..b9a02d5c 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url); | `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. | | `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. | -**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. +**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. **Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details. @@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, **Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events. -**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. +**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope. ## Scheduled (recurring) jobs @@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `activity.read` | | | `costs.read` | | | `issues.orchestration.read` | +| | `access.members.read` | +| | `access.invites.read` | +| | `authorization.grants.read` | +| | `authorization.policies.read` | +| | `authorization.audit.read` | | | `database.namespace.read` | | | `issues.create` | | | `issues.update` | @@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `local.folders` | | **Agent** | `agent.tools.register` | | | `agents.invoke` | +| | `access.members.write` | +| | `access.invites.write` | +| | `authorization.grants.write` | +| | `authorization.policies.write` | | | `agent.sessions.create` | | | `agent.sessions.list` | | | `agent.sessions.send` | diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 603ecd4f..8fc8fdfa 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -49,7 +49,7 @@ */ import type { PluginCapability } from "@paperclipai/shared"; -import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js"; +import type { WorkerHostCallContext, WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js"; import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -73,6 +73,19 @@ export class CapabilityDeniedError extends Error { } } +/** + * Thrown when a worker→host call asks for company-scoped data outside the + * company authorized for the current top-level plugin invocation. + */ +export class InvocationScopeDeniedError extends Error { + override readonly name = "InvocationScopeDeniedError"; + readonly code = PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED; + + constructor(pluginId: string, method: string, message: string) { + super(`Plugin "${pluginId}" is not allowed to perform "${method}": ${message}`); + } +} + // --------------------------------------------------------------------------- // Host service interfaces // --------------------------------------------------------------------------- @@ -181,6 +194,11 @@ export interface HostServices { resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise; }; + /** Provides `executionWorkspaces.get`. */ + executionWorkspaces: { + get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise; + }; + /** Provides `routines.managed.*`. */ routines: { managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise; @@ -252,6 +270,28 @@ export interface HostServices { create(params: WorkerToHostMethods["goals.create"][0]): Promise; update(params: WorkerToHostMethods["goals.update"][0]): Promise; }; + + /** Provides `access.members.*` and `access.invites.*`. */ + access: { + listMembers(params: WorkerToHostMethods["access.members.list"][0]): Promise; + getMember(params: WorkerToHostMethods["access.members.get"][0]): Promise; + updateMember(params: WorkerToHostMethods["access.members.update"][0]): Promise; + listInvites(params: WorkerToHostMethods["access.invites.list"][0]): Promise; + createInvite(params: WorkerToHostMethods["access.invites.create"][0]): Promise; + revokeInvite(params: WorkerToHostMethods["access.invites.revoke"][0]): Promise; + }; + + /** Provides authorization grant, policy, preview, and audit helpers. */ + authorization: { + listGrants(params: WorkerToHostMethods["authorization.grants.list"][0]): Promise; + setGrants(params: WorkerToHostMethods["authorization.grants.set"][0]): Promise; + policySummary(params: WorkerToHostMethods["authorization.policies.summary"][0]): Promise; + getPolicy(params: WorkerToHostMethods["authorization.policies.get"][0]): Promise; + updatePolicy(params: WorkerToHostMethods["authorization.policies.update"][0]): Promise; + previewAssignment(params: WorkerToHostMethods["authorization.policies.previewAssignment"][0]): Promise; + explainAssignment(params: WorkerToHostMethods["authorization.policies.explainAssignment"][0]): Promise; + searchAudit(params: WorkerToHostMethods["authorization.audit.search"][0]): Promise; + }; } // --------------------------------------------------------------------------- @@ -287,6 +327,7 @@ export interface HostClientFactoryOptions { */ type HostHandler = ( params: WorkerToHostMethods[M][0], + context?: WorkerHostCallContext, ) => Promise; /** @@ -368,6 +409,7 @@ const METHOD_CAPABILITY_MAP: Record(options.capabilities); + type CompanyScopeRequest = + | { kind: "none" } + | { kind: "single"; companyId: string } + | { kind: "all" }; + + const noCompanyScope: CompanyScopeRequest = { kind: "none" }; + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function requestedCompanyScope( + method: WorkerToHostMethodName, + params: unknown, + ): CompanyScopeRequest { + if (method === "companies.list") return { kind: "all" }; + if (!isRecord(params)) return noCompanyScope; + + const companyId = readNonEmptyString(params.companyId); + if (companyId) return { kind: "single", companyId }; + + if (params.scopeKind === "company") { + const scopeId = readNonEmptyString(params.scopeId); + return scopeId ? { kind: "single", companyId: scopeId } : { kind: "all" }; + } + + if (method === "events.subscribe" && isRecord(params.filter)) { + const filterCompanyId = readNonEmptyString(params.filter.companyId); + if (filterCompanyId) return { kind: "single", companyId: filterCompanyId }; + } + + return noCompanyScope; + } + + function requireInvocationCompanyScope( + method: WorkerToHostMethodName, + params: unknown, + context?: WorkerHostCallContext, + ): void { + const requested = requestedCompanyScope(method, params); + if (requested.kind === "none") return; + + if (context?.invalidInvocationScope) { + throw new InvocationScopeDeniedError( + pluginId, + method, + "the worker referenced a missing, expired, or unknown invocation scope", + ); + } + + const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + if (!allowedCompanyId) return; + + if (requested.kind === "all") { + if (method === "companies.list") return; + throw new InvocationScopeDeniedError( + pluginId, + method, + `the current invocation is scoped to company "${allowedCompanyId}"`, + ); + } + + if (requested.companyId !== allowedCompanyId) { + throw new InvocationScopeDeniedError( + pluginId, + method, + `requested company "${requested.companyId}" but the current invocation is scoped to company "${allowedCompanyId}"`, + ); + } + } + /** * Assert that the plugin has the required capability for a method. * Throws `CapabilityDeniedError` if the capability is missing. @@ -479,9 +614,10 @@ export function createHostClientHandlers( method: M, handler: HostHandler, ): HostHandler { - return async (params: WorkerToHostMethods[M][0]) => { + return async (params: WorkerToHostMethods[M][0], context?: WorkerHostCallContext) => { requireCapability(method); - return handler(params); + requireInvocationCompanyScope(method, params, context); + return handler(params, context); }; } @@ -585,8 +721,13 @@ export function createHostClientHandlers( }), // Companies - "companies.list": gated("companies.list", async (params) => { - return services.companies.list(params); + "companies.list": gated("companies.list", async (params, context) => { + const rows = await services.companies.list(params); + const allowedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + if (!allowedCompanyId) return rows; + return rows.filter((company) => + isRecord(company) && company.id === allowedCompanyId, + ) as WorkerToHostMethods["companies.list"][1]; }), "companies.get": gated("companies.get", async (params) => { return services.companies.get(params); @@ -608,6 +749,9 @@ export function createHostClientHandlers( "projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => { return services.projects.getWorkspaceForIssue(params); }), + "executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => { + return services.executionWorkspaces.get(params); + }), "projects.managed.get": gated("projects.managed.get", async (params) => { return services.projects.getManaged(params); }), @@ -763,6 +907,52 @@ export function createHostClientHandlers( "goals.update": gated("goals.update", async (params) => { return services.goals.update(params); }), + + // Access + "access.members.list": gated("access.members.list", async (params) => { + return services.access.listMembers(params); + }), + "access.members.get": gated("access.members.get", async (params) => { + return services.access.getMember(params); + }), + "access.members.update": gated("access.members.update", async (params) => { + return services.access.updateMember(params); + }), + "access.invites.list": gated("access.invites.list", async (params) => { + return services.access.listInvites(params); + }), + "access.invites.create": gated("access.invites.create", async (params) => { + return services.access.createInvite(params); + }), + "access.invites.revoke": gated("access.invites.revoke", async (params) => { + return services.access.revokeInvite(params); + }), + + // Authorization + "authorization.grants.list": gated("authorization.grants.list", async (params) => { + return services.authorization.listGrants(params); + }), + "authorization.grants.set": gated("authorization.grants.set", async (params) => { + return services.authorization.setGrants(params); + }), + "authorization.policies.summary": gated("authorization.policies.summary", async (params) => { + return services.authorization.policySummary(params); + }), + "authorization.policies.get": gated("authorization.policies.get", async (params) => { + return services.authorization.getPolicy(params); + }), + "authorization.policies.update": gated("authorization.policies.update", async (params) => { + return services.authorization.updatePolicy(params); + }), + "authorization.policies.previewAssignment": gated("authorization.policies.previewAssignment", async (params) => { + return services.authorization.previewAssignment(params); + }), + "authorization.policies.explainAssignment": gated("authorization.policies.explainAssignment", async (params) => { + return services.authorization.explainAssignment(params); + }), + "authorization.audit.search": gated("authorization.audit.search", async (params) => { + return services.authorization.searchAudit(params); + }), }; } diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 2753d72c..63ec9c70 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -58,6 +58,7 @@ export { createHostClientHandlers, getRequiredCapability, CapabilityDeniedError, + InvocationScopeDeniedError, } from "./host-client-factory.js"; // JSON-RPC protocol helpers and constants @@ -128,6 +129,8 @@ export type { // JSON-RPC protocol types export type { JsonRpcId, + JsonRpcInvocationScope, + JsonRpcInvocationContext, JsonRpcRequest, JsonRpcSuccessResponse, JsonRpcError, @@ -137,6 +140,9 @@ export type { JsonRpcMessage, JsonRpcErrorCode, PluginRpcErrorCode, + PluginInvocationScope, + PluginInvocationContext, + WorkerHostCallContext, InitializeParams, InitializeResult, ConfigChangedParams, @@ -145,6 +151,9 @@ export type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorType, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, @@ -197,6 +206,7 @@ export type { PluginStateClient, PluginEntitiesClient, PluginProjectsClient, + PluginExecutionWorkspacesClient, PluginSkillsClient, PluginCompaniesClient, PluginIssuesClient, @@ -217,6 +227,17 @@ export type { PluginIssueSubtree, PluginIssueSummariesClient, PluginAgentsClient, + PluginAccessClient, + PluginAccessMembersClient, + PluginAccessInvitesClient, + PluginAccessMember, + PluginAccessInvite, + PluginAuthorizationClient, + PluginAuthorizationPolicySummary, + PluginAuthorizationPolicyRecord, + PluginAssignmentPreviewInput, + PluginAuthorizationDecisionResult, + PluginAuthorizationAuditEntry, PluginAgentSessionsClient, AgentSession, AgentSessionEvent, @@ -244,6 +265,7 @@ export type { PluginEntityRecord, PluginEntityQuery, PluginWorkspace, + PluginExecutionWorkspaceMetadata, Company, Project, Issue, @@ -251,7 +273,12 @@ export type { IssueDocumentSummary, Agent, Goal, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, PluginDatabaseClient, + HumanCompanyMembershipRole, + MembershipStatus, } from "./types.js"; // Manifest and constant types re-exported from @paperclipai/shared @@ -351,6 +378,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_STATE_SCOPE_KINDS, PLUGIN_JOB_STATUSES, PLUGIN_JOB_RUN_STATUSES, @@ -358,4 +386,9 @@ export { PLUGIN_WEBHOOK_DELIVERY_STATUSES, PLUGIN_EVENT_TYPES, PLUGIN_BRIDGE_ERROR_CODES, + PERMISSION_KEYS, + HUMAN_COMPANY_MEMBERSHIP_ROLES, + HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, + MEMBERSHIP_STATUSES, + PRINCIPAL_TYPES, } from "@paperclipai/shared"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 8cf74f68..28e00703 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -39,6 +39,7 @@ import type { Agent, Goal, PluginLocalFolderDeclaration, + PrincipalPermissionGrant, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -51,11 +52,19 @@ import type { PluginIssueWakeupBatchResult, PluginIssueWakeupResult, PluginJobContext, + PluginExecutionWorkspaceMetadata, PluginWorkspace, ToolRunContext, ToolResult, PluginLocalFolderListing, PluginLocalFolderStatus, + PluginAccessInvite, + PluginAccessMember, + PluginAssignmentPreviewInput, + PluginAuthorizationAuditEntry, + PluginAuthorizationDecisionResult, + PluginAuthorizationPolicyRecord, + PluginAuthorizationPolicySummary, } from "./types.js"; import type { PluginHealthDiagnostics, @@ -78,6 +87,19 @@ export const JSONRPC_VERSION = "2.0" as const; */ export type JsonRpcId = string | number; +/** + * Host-owned scope attached to a host→worker invocation. Workers may echo the + * invocation id on nested worker→host calls, but they never author this scope. + */ +export interface JsonRpcInvocationScope { + readonly companyId?: string | null; +} + +export interface JsonRpcInvocationContext { + readonly id: string; + readonly scope: JsonRpcInvocationScope; +} + /** * A JSON-RPC 2.0 request message. * @@ -95,6 +117,14 @@ export interface JsonRpcRequest< readonly method: TMethod; /** Structured parameters for the method call. */ readonly params: TParams; + /** + * Host-issued metadata for the top-level plugin invocation that is currently + * executing. The worker treats this as opaque and echoes only the id on + * worker→host calls made from the same async execution context. + */ + readonly paperclipInvocation?: PluginInvocationContext; + /** Opaque top-level invocation id echoed by worker→host requests. */ + readonly paperclipInvocationId?: string; } /** @@ -155,6 +185,13 @@ export interface JsonRpcNotification< readonly method: TMethod; /** Structured parameters for the notification. */ readonly params: TParams; + /** + * Host-issued metadata for host→worker push notifications such as events. + * Worker→host notifications echo only `paperclipInvocationId`. + */ + readonly paperclipInvocation?: PluginInvocationContext; + /** Opaque top-level invocation id echoed by worker→host notifications. */ + readonly paperclipInvocationId?: string; } /** @@ -209,6 +246,8 @@ export const PLUGIN_RPC_ERROR_CODES = { TIMEOUT: -32003, /** The worker does not implement the requested optional method. */ METHOD_NOT_IMPLEMENTED: -32004, + /** The worker→host call attempted to escape the current invocation company scope. */ + INVOCATION_SCOPE_DENIED: -32005, /** A catch-all for errors that do not fit other categories. */ UNKNOWN: -32099, } as const; @@ -216,6 +255,36 @@ export const PLUGIN_RPC_ERROR_CODES = { export type PluginRpcErrorCode = (typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES]; +// --------------------------------------------------------------------------- +// Invocation scope metadata +// --------------------------------------------------------------------------- + +/** + * Company scope attached by the host to one top-level plugin invocation. + * Absence of this metadata means the invocation is instance/global scoped. + */ +export interface PluginInvocationScope { + companyId: string; +} + +/** + * Opaque invocation metadata generated by the host. Workers must not derive or + * mutate this. They only echo the id on nested worker→host RPC calls. + */ +export interface PluginInvocationContext { + id: string; + scope: PluginInvocationScope; +} + +/** + * Context provided to host-side worker→host handlers after the worker echoes a + * host-issued invocation id. + */ +export interface WorkerHostCallContext { + invocationScope?: PluginInvocationScope | null; + invalidInvocationScope?: boolean; +} + // --------------------------------------------------------------------------- // Host → Worker Method Signatures (§13 Host-Worker Protocol) // --------------------------------------------------------------------------- @@ -301,6 +370,8 @@ export interface RunJobParams { export interface GetDataParams { /** Plugin-defined data key (e.g. `"sync-health"`). */ key: string; + /** Host-authorized active company scope, when this bridge call is company-scoped. */ + companyId?: string | null; /** Context and query parameters from the UI. */ params: Record; /** Optional launcher/container metadata from the host render environment. */ @@ -312,11 +383,37 @@ export interface GetDataParams { * * @see PLUGIN_SPEC.md §13.9 — `performAction` */ +export type PluginPerformActionActorType = "user" | "agent" | "system"; + +export interface PluginPerformActionActorContext { + /** Authenticated principal type resolved by the Paperclip host. */ + type: PluginPerformActionActorType; + /** Authenticated board user id when `type === "user"`, otherwise null. */ + userId: string | null; + /** Authenticated agent id when `type === "agent"`, otherwise null. */ + agentId: string | null; + /** Authenticated heartbeat/run id when available. */ + runId: string | null; + /** Company id authorized by the host bridge for this action, when applicable. */ + companyId: string | null; +} + +export interface PluginPerformActionContext { + /** Immutable authenticated actor context supplied by the host. */ + actor: Readonly; + /** Convenience alias for `actor.companyId`. */ + companyId: string | null; +} + export interface PerformActionParams { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; + /** Host-authorized active company scope, when this bridge call is company-scoped. */ + companyId?: string | null; /** Action parameters from the UI. */ params: Record; + /** Authenticated actor context resolved by the host, never by caller params. */ + actorContext?: PluginPerformActionActorContext | null; /** Optional launcher/container metadata from the host render environment. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } @@ -792,6 +889,13 @@ export interface WorkerToHostMethods { params: { issueId: string; companyId: string }, result: PluginWorkspace | null, ]; + "executionWorkspaces.get": [ + params: { + workspaceId: string; + companyId: string; + }, + result: PluginExecutionWorkspaceMetadata | null, + ]; "projects.managed.get": [ params: { projectKey: string; companyId: string }, result: PluginManagedProjectResolution, @@ -1135,6 +1239,105 @@ export interface WorkerToHostMethods { }, result: Goal, ]; + + // Access + "access.members.list": [ + params: { companyId: string; includeArchived?: boolean }, + result: PluginAccessMember[], + ]; + "access.members.get": [ + params: { memberId: string; companyId: string }, + result: PluginAccessMember | null, + ]; + "access.members.update": [ + params: { + memberId: string; + companyId: string; + patch: { + membershipRole?: string | null; + status?: "pending" | "active" | "suspended"; + }; + }, + result: PluginAccessMember, + ]; + "access.invites.list": [ + params: { + companyId: string; + state?: "active" | "revoked" | "accepted" | "expired"; + limit?: number; + offset?: number; + }, + result: { invites: PluginAccessInvite[]; nextOffset: number | null }, + ]; + "access.invites.create": [ + params: { + companyId: string; + allowedJoinTypes?: "human" | "agent" | "both"; + humanRole?: string | null; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }, + result: PluginAccessInvite & { token: string }, + ]; + "access.invites.revoke": [ + params: { inviteId: string; companyId: string }, + result: PluginAccessInvite, + ]; + + // Authorization + "authorization.grants.list": [ + params: { companyId: string; principalType?: string; principalId?: string }, + result: PrincipalPermissionGrant[], + ]; + "authorization.grants.set": [ + params: { + companyId: string; + principalType: string; + principalId: string; + grants: Array<{ permissionKey: string; scope?: Record | null }>; + grantedByUserId?: string | null; + }, + result: PrincipalPermissionGrant[], + ]; + "authorization.policies.summary": [ + params: { companyId: string }, + result: PluginAuthorizationPolicySummary, + ]; + "authorization.policies.get": [ + params: { companyId: string; resourceType: "company" | "agent" | "project" | "issue"; resourceId: string }, + result: PluginAuthorizationPolicyRecord | null, + ]; + "authorization.policies.update": [ + params: { + companyId: string; + resourceType: "company" | "agent" | "project" | "issue"; + resourceId: string; + policy: Record | null; + }, + result: PluginAuthorizationPolicyRecord, + ]; + "authorization.policies.previewAssignment": [ + params: PluginAssignmentPreviewInput, + result: PluginAuthorizationDecisionResult, + ]; + "authorization.policies.explainAssignment": [ + params: PluginAssignmentPreviewInput, + result: PluginAuthorizationDecisionResult, + ]; + "authorization.audit.search": [ + params: { + companyId: string; + action?: string; + actorType?: string; + actorId?: string; + entityType?: string; + entityId?: string; + decision?: string; + limit?: number; + offset?: number; + }, + result: PluginAuthorizationAuditEntry[], + ]; } /** Union of all worker→host method names. */ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index ce2e2d40..f8f1fc26 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -33,10 +33,15 @@ import type { ToolResult, ToolRunContext, PluginWorkspace, + PluginExecutionWorkspaceMetadata, AgentSession, AgentSessionEvent, PluginLocalFolderEntry, PluginLocalFolderStatus, + PluginAccessMember, + PrincipalPermissionGrant, + PermissionKey, + PrincipalType, } from "./types.js"; import type { PluginEnvironmentValidateConfigParams, @@ -52,6 +57,8 @@ import type { PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult, + PluginPerformActionActorContext, + PluginPerformActionContext, } from "./protocol.js"; export interface TestHarnessOptions { @@ -69,10 +76,24 @@ export interface TestHarnessLogEntry { meta?: Record; } +export interface TestHarnessPerformActionOptions { + /** + * Authenticated actor context to expose to the action handler. Omitted fields + * default to null, and `type` defaults to `system`. + */ + actor?: Partial | null; + /** + * Host-authorized company scope. When provided, this is injected into + * `params.companyId` so tests match the production bridge's anti-spoofing + * behavior. + */ + companyId?: string | null; +} + export interface TestHarness { /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ ctx: PluginContext; - /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */ + /** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */ seed(input: { companies?: Company[]; projects?: Project[]; @@ -80,6 +101,10 @@ export interface TestHarness { issueComments?: IssueComment[]; agents?: Agent[]; goals?: Goal[]; + projectWorkspaces?: PluginWorkspace[]; + executionWorkspaces?: PluginExecutionWorkspaceMetadata[]; + accessMembers?: PluginAccessMember[]; + principalGrants?: PrincipalPermissionGrant[]; }): void; setConfig(config: Record): void; /** Dispatch a host or plugin event to registered handlers. */ @@ -89,7 +114,11 @@ export interface TestHarness { /** Invoke a `ctx.data.register(...)` handler by key. */ getData(key: string, params?: Record): Promise; /** Invoke a `ctx.actions.register(...)` handler by key. */ - performAction(key: string, params?: Record): Promise; + performAction( + key: string, + params?: Record, + options?: TestHarnessPerformActionOptions, + ): Promise; /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ executeTool(name: string, params: unknown, runCtx?: Partial): Promise; /** Read raw in-memory state for assertions. */ @@ -437,7 +466,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const issueDocuments = new Map(); const agents = new Map(); const goals = new Map(); + const accessMembers = new Map(); + const principalGrants = new Map(); + + function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) { + return `${companyId}:${principalType}:${principalId}`; + } + function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) { + return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? []; + } + function setPrincipalGrants( + companyId: string, + principalType: PrincipalType, + principalId: string, + grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>, + ) { + const stamped = grants.map((grant) => ({ + principalType, + principalId, + permissionKey: grant.permissionKey, + scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null, + })) as PrincipalPermissionGrant[]; + principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped); + const member = [...accessMembers.values()].find( + (entry) => + entry.companyId === companyId + && entry.principalType === principalType + && entry.principalId === principalId, + ); + if (member) { + accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() }); + } + return stamped; + } const projectWorkspaces = new Map(); + const executionWorkspaces = new Map(); const localFolderStatuses = new Map(); const localFolderFiles = new Map(); @@ -448,7 +511,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const jobs = new Map Promise>(); const launchers = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map Promise>(); function localFolderKey(companyId: string, folderKey: string): string { @@ -459,6 +525,41 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return `${localFolderKey(companyId, folderKey)}:${relativePath}`; } + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFor( + params: Record, + options?: TestHarnessPerformActionOptions, + ): PluginPerformActionContext { + const actorInput = options?.actor ?? null; + const companyId = stringOrNull(options?.companyId) ?? stringOrNull(actorInput?.companyId) ?? stringOrNull(params.companyId); + const actor = Object.freeze({ + type: actorTypeOrSystem(actorInput?.type), + userId: stringOrNull(actorInput?.userId), + agentId: stringOrNull(actorInput?.agentId), + runId: stringOrNull(actorInput?.runId), + companyId, + }); + return Object.freeze({ actor, companyId }); + } + + function paramsWithHostCompanyScope( + params: Record, + context: PluginPerformActionContext, + options?: TestHarnessPerformActionOptions, + ): Record { + if (Object.prototype.hasOwnProperty.call(options ?? {}, "companyId")) { + return context.companyId ? { ...params, companyId: context.companyId } : { ...params }; + } + return params; + } + function normalizeLocalFolderRelativePath(relativePath: string): string { const parts: string[] = []; for (const segment of relativePath.split(/[\\/]+/)) { @@ -975,6 +1076,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { }, }, }, + executionWorkspaces: { + async get(workspaceId, companyId) { + requireCapability(manifest, capabilitySet, "execution.workspaces.read"); + const workspace = executionWorkspaces.get(workspaceId); + return workspace?.companyId === companyId ? workspace : null; + }, + }, routines: { managed: { async get(routineKey, companyId) { @@ -1604,6 +1712,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { createdByUserId: existing?.createdByUserId ?? null, updatedByAgentId: null, updatedByUserId: null, + lockedAt: existing?.lockedAt ?? null, + lockedByAgentId: existing?.lockedByAgentId ?? null, + lockedByUserId: existing?.lockedByUserId ?? null, createdAt: existing?.createdAt ?? now, updatedAt: now, body: input.body, @@ -1969,6 +2080,156 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return updated; }, }, + access: { + members: { + async list(input) { + requireCapability(manifest, capabilitySet, "access.members.read"); + const cid = requireCompanyId(input.companyId); + const includeArchived = input.includeArchived === true; + return [...accessMembers.values()] + .filter((member) => member.companyId === cid) + .filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"])) + .map((member) => ({ + ...member, + grants: getPrincipalGrants(cid, member.principalType, member.principalId), + })); + }, + async get(memberId, companyId) { + requireCapability(manifest, capabilitySet, "access.members.read"); + const cid = requireCompanyId(companyId); + const member = accessMembers.get(memberId); + if (!member || member.companyId !== cid) return null; + return { + ...member, + grants: getPrincipalGrants(cid, member.principalType, member.principalId), + }; + }, + async update(memberId, patch, companyId) { + requireCapability(manifest, capabilitySet, "access.members.write"); + const cid = requireCompanyId(companyId); + const member = accessMembers.get(memberId); + if (!member || member.companyId !== cid) { + throw new Error(`Membership not found: ${memberId}`); + } + const updated: PluginAccessMember = { + ...member, + membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole, + status: patch.status === undefined ? member.status : patch.status, + updatedAt: new Date().toISOString(), + }; + accessMembers.set(memberId, updated); + return { + ...updated, + grants: getPrincipalGrants(cid, updated.principalType, updated.principalId), + }; + }, + }, + invites: { + async list(input) { + requireCapability(manifest, capabilitySet, "access.invites.read"); + requireCompanyId(input.companyId); + return { invites: [], nextOffset: null }; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "access.invites.write"); + requireCompanyId(input.companyId); + throw new Error("Invite creation is not implemented in the plugin test harness"); + }, + async revoke(inviteId, companyId) { + requireCapability(manifest, capabilitySet, "access.invites.write"); + requireCompanyId(companyId); + throw new Error(`Invite not found: ${inviteId}`); + }, + }, + }, + authorization: { + grants: { + async list(input) { + requireCapability(manifest, capabilitySet, "authorization.grants.read"); + const cid = requireCompanyId(input.companyId); + if (input.principalType && input.principalId) { + return getPrincipalGrants(cid, input.principalType, input.principalId); + } + const out: PrincipalPermissionGrant[] = []; + for (const [key, grants] of principalGrants.entries()) { + if (!key.startsWith(`${cid}:`)) continue; + for (const grant of grants) { + if (input.principalType && grant.principalType !== input.principalType) continue; + if (input.principalId && grant.principalId !== input.principalId) continue; + out.push(grant); + } + } + return out; + }, + async set(input) { + requireCapability(manifest, capabilitySet, "authorization.grants.write"); + const cid = requireCompanyId(input.companyId); + return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants); + }, + }, + policies: { + async summary(companyId) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + const cid = requireCompanyId(companyId); + const members = [...accessMembers.values()].filter((member) => member.companyId === cid); + let grantCount = 0; + for (const [key, grants] of principalGrants.entries()) { + if (key.startsWith(`${cid}:`)) grantCount += grants.length; + } + return { + companyId: cid, + permissionsMode: "simple", + memberCount: members.length, + activeMemberCount: members.filter((member) => member.status === "active").length, + grantCount, + advancedPolicyAvailable: false, + }; + }, + async get(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return null; + }, + async update(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.write"); + const cid = requireCompanyId(input.companyId); + return { + companyId: cid, + resourceType: input.resourceType, + resourceId: input.resourceId, + policy: input.policy, + updatedAt: new Date().toISOString(), + }; + }, + async previewAssignment(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return { + allowed: true, + action: "issue.assign", + explanation: "Allowed by simple company-wide defaults in the plugin test harness.", + reason: "simple_mode", + }; + }, + async explainAssignment(input) { + requireCapability(manifest, capabilitySet, "authorization.policies.read"); + requireCompanyId(input.companyId); + return { + allowed: true, + action: "issue.assign", + explanation: "Allowed by simple company-wide defaults in the plugin test harness.", + reason: "simple_mode", + }; + }, + }, + audit: { + async search(input) { + requireCapability(manifest, capabilitySet, "authorization.audit.read"); + requireCompanyId(input.companyId); + return []; + }, + }, + }, data: { register(key, handler) { dataHandlers.set(key, handler); @@ -2045,6 +2306,18 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row); + for (const row of input.projectWorkspaces ?? []) { + const list = projectWorkspaces.get(row.projectId) ?? []; + list.push(row); + projectWorkspaces.set(row.projectId, list); + } + for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row); + for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row); + for (const row of input.principalGrants ?? []) { + const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? []; + list.push(row); + principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list); + } }, setConfig(config) { currentConfig = { ...config }; @@ -2087,10 +2360,15 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (!handler) throw new Error(`No data handler registered for '${key}'`); return await handler(params) as T; }, - async performAction(key: string, params: Record = {}) { + async performAction( + key: string, + params: Record = {}, + options?: TestHarnessPerformActionOptions, + ) { const handler = actionHandlers.get(key); if (!handler) throw new Error(`No action handler registered for '${key}'`); - return await handler(params) as T; + const context = actionContextFor(params, options); + return await handler(paramsWithHostCompanyScope(params, context, options), context) as T; }, async executeTool(name: string, params: unknown, runCtx: Partial = {}) { const handler = toolHandlers.get(name); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index bb9b86d3..90af99b4 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -39,7 +39,14 @@ import type { RoutineRun, Agent, Goal, + HumanCompanyMembershipRole, + InviteJoinType, + MembershipStatus, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, } from "@paperclipai/shared"; +import type { PluginPerformActionContext } from "./protocol.js"; // --------------------------------------------------------------------------- // Re-exports from @paperclipai/shared (plugin authors import from one place) @@ -120,6 +127,12 @@ export type { IssueSurfaceVisibility, Agent, Goal, + HumanCompanyMembershipRole, + InviteJoinType, + MembershipStatus, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -344,6 +357,12 @@ export interface PluginWorkspace { name: string; /** Absolute filesystem path to the workspace directory. */ path: string; + /** Repository URL, when known. */ + repoUrl: string | null; + /** Checkout/ref requested for the workspace, when known. */ + repoRef: string | null; + /** Default comparison ref for workspace tooling, when known. */ + defaultRef: string | null; /** Whether this is the project's primary workspace. */ isPrimary: boolean; /** ISO 8601 creation timestamp. */ @@ -352,6 +371,40 @@ export interface PluginWorkspace { updatedAt: string; } +// --------------------------------------------------------------------------- +// Execution workspace metadata (read-only via ctx.executionWorkspaces) +// --------------------------------------------------------------------------- + +/** + * Plugin-safe execution workspace metadata provided by the host. This exposes + * the local/repository coordinates plugins need for workspace tooling without + * giving the SDK a host-owned diff engine. + */ +export interface PluginExecutionWorkspaceMetadata { + /** UUID primary key. */ + id: string; + /** UUID of the owning company. */ + companyId: string; + /** UUID of the parent project. */ + projectId: string; + /** UUID of the backing project workspace, when present. */ + projectWorkspaceId: string | null; + /** Absolute filesystem path to the workspace when locally realized. */ + path: string | null; + /** Current working directory for local workspace tooling. */ + cwd: string | null; + /** Repository URL, when known. */ + repoUrl: string | null; + /** Base ref configured for the workspace, when known. */ + baseRef: string | null; + /** Branch name configured for the workspace, when known. */ + branchName: string | null; + /** Host provider type for the realized workspace. */ + providerType: string | null; + /** Provider metadata already safe for plugin consumption. */ + providerMetadata: Record | null; +} + // --------------------------------------------------------------------------- // Host API surfaces exposed via PluginContext // --------------------------------------------------------------------------- @@ -818,6 +871,19 @@ export interface PluginProjectsClient { }; } +/** + * `ctx.executionWorkspaces` — read execution workspace metadata. + * + * Requires `execution.workspaces.read`. + */ +export interface PluginExecutionWorkspacesClient { + /** + * Return plugin-safe metadata for an execution workspace. The host enforces + * company access before returning any workspace coordinates. + */ + get(workspaceId: string, companyId: string): Promise; +} + /** * `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines. * @@ -892,9 +958,12 @@ export interface PluginActionsClient { * Register a handler for a plugin-defined action key. * * @param key - Stable string identifier for this action (e.g. `"resync"`) - * @param handler - Async function that receives action params and returns a result + * @param handler - Async function that receives action params plus immutable host actor context and returns a result */ - register(key: string, handler: (params: Record) => Promise): void; + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void; } /** @@ -1523,6 +1592,169 @@ export interface PluginGoalsClient { ): Promise; } +// --------------------------------------------------------------------------- +// Access and Authorization +// --------------------------------------------------------------------------- + +export interface PluginAccessMember { + id: string; + companyId: string; + principalType: PrincipalType; + principalId: string; + status: MembershipStatus; + membershipRole: string | null; + grants: PrincipalPermissionGrant[]; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface PluginAccessInvite { + id: string; + companyId: string | null; + inviteType: string; + allowedJoinTypes: InviteJoinType; + defaultsPayload: Record | null; + expiresAt: Date | string; + invitedByUserId: string | null; + revokedAt: Date | string | null; + acceptedAt: Date | string | null; + createdAt: Date | string; + updatedAt: Date | string; + state: "active" | "revoked" | "accepted" | "expired"; +} + +export interface PluginAccessMembersClient { + list(input: { companyId: string; includeArchived?: boolean }): Promise; + get(memberId: string, companyId: string): Promise; + update( + memberId: string, + patch: { + membershipRole?: HumanCompanyMembershipRole | null; + status?: Extract; + }, + companyId: string, + ): Promise; +} + +export interface PluginAccessInvitesClient { + list(input: { + companyId: string; + state?: PluginAccessInvite["state"]; + limit?: number; + offset?: number; + }): Promise<{ invites: PluginAccessInvite[]; nextOffset: number | null }>; + create(input: { + companyId: string; + allowedJoinTypes?: InviteJoinType; + humanRole?: HumanCompanyMembershipRole | null; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }): Promise; + revoke(inviteId: string, companyId: string): Promise; +} + +export interface PluginAccessClient { + /** Read and update company memberships. Requires `access.members.*`. */ + members: PluginAccessMembersClient; + /** Read, create, and revoke company invites. Requires `access.invites.*`. */ + invites: PluginAccessInvitesClient; +} + +export interface PluginAuthorizationPolicySummary { + companyId: string; + permissionsMode: "simple"; + memberCount: number; + activeMemberCount: number; + grantCount: number; + advancedPolicyAvailable: false; +} + +export interface PluginAuthorizationPolicyRecord { + resourceType: "company" | "agent" | "project" | "issue"; + resourceId: string; + companyId: string; + policy: Record | null; + updatedAt: Date | string | null; +} + +export interface PluginAssignmentPreviewInput { + companyId: string; + actor: + | { type: "board"; userId?: string | null; companyIds?: string[]; isInstanceAdmin?: boolean } + | { type: "agent"; agentId: string; companyId: string }; + target: { + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string | null; + }; +} + +export interface PluginAuthorizationDecisionResult { + allowed: boolean; + action: string; + explanation: string; + reason: string; + grant?: { + principalType: PrincipalType; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + }; +} + +export interface PluginAuthorizationAuditEntry { + id: string; + companyId: string; + actorType: string; + actorId: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: Date | string; +} + +export interface PluginAuthorizationClient { + grants: { + list(input: { companyId: string; principalType?: PrincipalType; principalId?: string }): Promise; + set(input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + grants: Array<{ permissionKey: PermissionKey; scope?: Record | null }>; + grantedByUserId?: string | null; + }): Promise; + }; + policies: { + summary(companyId: string): Promise; + get(input: { companyId: string; resourceType: PluginAuthorizationPolicyRecord["resourceType"]; resourceId: string }): Promise; + update(input: { + companyId: string; + resourceType: PluginAuthorizationPolicyRecord["resourceType"]; + resourceId: string; + policy: Record | null; + }): Promise; + previewAssignment(input: PluginAssignmentPreviewInput): Promise; + explainAssignment(input: PluginAssignmentPreviewInput): Promise; + }; + audit: { + search(input: { + companyId: string; + action?: string; + actorType?: string; + actorId?: string; + entityType?: string; + entityId?: string; + decision?: string; + limit?: number; + offset?: number; + }): Promise; + }; +} + // --------------------------------------------------------------------------- // Streaming (worker → UI push channel) // --------------------------------------------------------------------------- @@ -1642,6 +1874,9 @@ export interface PluginContext { /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ projects: PluginProjectsClient; + /** Read execution workspace metadata. Requires `execution.workspaces.read`. */ + executionWorkspaces: PluginExecutionWorkspacesClient; + /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ routines: PluginRoutinesClient; @@ -1660,6 +1895,12 @@ export interface PluginContext { /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */ goals: PluginGoalsClient; + /** Read and manage access memberships and invites. Requires `access.*` capabilities. */ + access: PluginAccessClient; + + /** Read and manage authorization grants, policy summaries, previews, and audit entries. Requires `authorization.*` capabilities. */ + authorization: PluginAuthorizationClient; + /** Register getData handlers for the plugin's UI components. */ data: PluginDataClient; diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index 8f90af70..e2ce8f63 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -146,6 +146,7 @@ export type { // Slot component prop interfaces export type { PluginPageProps, + PluginCompanySettingsPageProps, PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index b8216836..10478216 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -54,6 +54,7 @@ export type { * Error codes: * - `WORKER_UNAVAILABLE` — plugin worker is not running * - `CAPABILITY_DENIED` — plugin lacks the required capability + * - `INVOCATION_SCOPE_DENIED` — plugin call escaped the invocation company scope * - `WORKER_ERROR` — worker returned an error from its handler * - `TIMEOUT` — worker did not respond within the configured timeout * - `UNKNOWN` — unexpected bridge-level failure @@ -229,6 +230,18 @@ export interface PluginPageProps { context: PluginHostContext; } +/** + * Props passed to a plugin company settings page component. + * + * A company settings page is mounted at + * `/:companyPrefix/company/settings/:routePath` and always receives the active + * company id and prefix when available. + */ +export interface PluginCompanySettingsPageProps { + /** The current host context, including company id and prefix. */ + context: PluginHostContext; +} + /** * Props passed to a plugin dashboard widget component. * diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 643f9b0d..a135d1f0 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -35,6 +35,7 @@ */ import fs from "node:fs"; +import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; @@ -66,6 +67,7 @@ import type { } from "./types.js"; import type { JsonRpcId, + JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, InitializeParams, @@ -76,6 +78,8 @@ import type { RunJobParams, GetDataParams, PerformActionParams, + PluginPerformActionActorContext, + PluginPerformActionContext, ExecuteToolParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, @@ -85,6 +89,7 @@ import type { PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentProbeParams, + PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, } from "./protocol.js"; @@ -279,13 +284,17 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost let manifest: PaperclipPluginManifestV1 | null = null; let currentConfig: Record = {}; let databaseNamespace: string | null = null; + const invocationContextStorage = new AsyncLocalStorage(); // Plugin handler registrations (populated during setup()) const eventHandlers: EventRegistration[] = []; const jobHandlers = new Map Promise>(); const launcherRegistrations = new Map(); const dataHandlers = new Map) => Promise>(); - const actionHandlers = new Map) => Promise>(); + const actionHandlers = new Map< + string, + (params: Record, context: PluginPerformActionContext) => Promise + >(); const toolHandlers = new Map; fn: (params: unknown, runCtx: ToolRunContext) => Promise; @@ -365,7 +374,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); try { - const request = createRequest(method, params, id); + const activeInvocation = invocationContextStorage.getStore(); + const request = { + ...createRequest(method, params, id), + ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), + }; sendMessage(request); } catch (err) { settle(reject, err instanceof Error ? err : new Error(String(err))); @@ -378,7 +391,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost */ function notifyHost(method: string, params: unknown): void { try { - sendMessage(createNotification(method, params)); + const activeInvocation = invocationContextStorage.getStore(); + sendMessage({ + ...createNotification(method, params), + ...(activeInvocation ? { paperclipInvocationId: activeInvocation.id } : {}), + }); } catch { // Swallow — the host may have closed stdin } @@ -657,6 +674,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + executionWorkspaces: { + async get(workspaceId: string, companyId: string) { + return callHost("executionWorkspaces.get", { workspaceId, companyId }); + }, + }, + routines: { managed: { async get(routineKey: string, companyId: string) { @@ -1080,6 +1103,85 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + access: { + members: { + async list(input) { + return callHost("access.members.list", { + companyId: input.companyId, + includeArchived: input.includeArchived, + }); + }, + + async get(memberId: string, companyId: string) { + return callHost("access.members.get", { memberId, companyId }); + }, + + async update(memberId: string, patch, companyId: string) { + return callHost("access.members.update", { memberId, patch, companyId }); + }, + }, + + invites: { + async list(input) { + return callHost("access.invites.list", { + companyId: input.companyId, + state: input.state, + limit: input.limit, + offset: input.offset, + }); + }, + + async create(input) { + return callHost("access.invites.create", { + companyId: input.companyId, + allowedJoinTypes: input.allowedJoinTypes, + humanRole: input.humanRole, + defaultsPayload: input.defaultsPayload, + agentMessage: input.agentMessage, + }); + }, + + async revoke(inviteId: string, companyId: string) { + return callHost("access.invites.revoke", { inviteId, companyId }); + }, + }, + }, + + authorization: { + grants: { + async list(input) { + return callHost("authorization.grants.list", input); + }, + async set(input) { + return callHost("authorization.grants.set", input); + }, + }, + + policies: { + async summary(companyId: string) { + return callHost("authorization.policies.summary", { companyId }); + }, + async get(input) { + return callHost("authorization.policies.get", input); + }, + async update(input) { + return callHost("authorization.policies.update", input); + }, + async previewAssignment(input) { + return callHost("authorization.policies.previewAssignment", input); + }, + async explainAssignment(input) { + return callHost("authorization.policies.explainAssignment", input); + }, + }, + + audit: { + async search(input) { + return callHost("authorization.audit.search", input); + }, + }, + }, + data: { register(key: string, handler: (params: Record) => Promise): void { dataHandlers.set(key, handler); @@ -1087,7 +1189,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, actions: { - register(key: string, handler: (params: Record) => Promise): void { + register( + key: string, + handler: (params: Record, context: PluginPerformActionContext) => Promise, + ): void { actionHandlers.set(key, handler); }, }, @@ -1169,7 +1274,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost const { id, method, params } = request; try { - const result = await dispatchMethod(method, params); + const invoke = () => dispatchMethod(method, params); + const result = request.paperclipInvocation + ? await invocationContextStorage.run(request.paperclipInvocation, invoke) + : await invoke(); sendMessage(createSuccessResponse(id, result ?? null)); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -1407,11 +1515,36 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (!handler) { throw new Error(`No data handler registered for key "${params.key}"`); } - return handler( - params.renderEnvironment === undefined - ? params.params - : { ...params.params, renderEnvironment: params.renderEnvironment }, - ); + return handler({ + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }); + } + + function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function actorTypeOrSystem(value: unknown): PluginPerformActionActorContext["type"] { + return value === "user" || value === "agent" || value === "system" ? value : "system"; + } + + function actionContextFromParams(params: PerformActionParams): PluginPerformActionContext { + const rawActor = params.actorContext && typeof params.actorContext === "object" + ? params.actorContext + : null; + const actor = Object.freeze({ + type: actorTypeOrSystem(rawActor?.type), + userId: stringOrNull(rawActor?.userId), + agentId: stringOrNull(rawActor?.agentId), + runId: stringOrNull(rawActor?.runId), + companyId: stringOrNull(rawActor?.companyId), + }); + return Object.freeze({ + actor, + companyId: actor.companyId, + }); } async function handlePerformAction(params: PerformActionParams): Promise { @@ -1420,9 +1553,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost throw new Error(`No action handler registered for key "${params.key}"`); } return handler( - params.renderEnvironment === undefined - ? params.params - : { ...params.params, renderEnvironment: params.renderEnvironment }, + { + ...params.params, + ...(params.companyId === undefined ? {} : { companyId: params.companyId }), + ...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }), + }, + actionContextFromParams(params), ); } @@ -1591,14 +1727,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }); } else if (isJsonRpcNotification(message)) { // Dispatch host→worker push notifications - const notif = message as { method: string; params?: unknown }; + const notif = message as JsonRpcNotification & { method: string; params?: unknown }; + const runNotification = (fn: () => void | Promise) => { + if (notif.paperclipInvocation) { + return invocationContextStorage.run(notif.paperclipInvocation, fn); + } + return fn(); + }; if (notif.method === "agents.sessions.event" && notif.params) { const event = notif.params as AgentSessionEvent; const cb = sessionEventCallbacks.get(event.sessionId); if (cb) cb(event); } else if (notif.method === "onEvent" && notif.params) { // Plugin event bus notifications — dispatch to registered event handlers - handleOnEvent(notif.params as OnEventParams).catch((err) => { + Promise.resolve(runNotification(() => handleOnEvent(notif.params as OnEventParams))).catch((err) => { notifyHost("log", { level: "error", message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/plugins/sdk/tests/host-client-factory.test.ts b/packages/plugins/sdk/tests/host-client-factory.test.ts new file mode 100644 index 00000000..eb79043e --- /dev/null +++ b/packages/plugins/sdk/tests/host-client-factory.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HostServices } from "../src/host-client-factory.js"; +import { + CapabilityDeniedError, + createHostClientHandlers, + InvocationScopeDeniedError, +} from "../src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../src/protocol.js"; + +describe("createHostClientHandlers invocation company scope", () => { + it("rejects company-scoped host calls outside the current invocation company", async () => { + const projectsList = vi.fn(async () => []); + const services = { + projects: { + list: projectsList, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["projects.read"], + services, + }); + + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + await expect( + handlers["projects.list"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); + expect(projectsList).not.toHaveBeenCalled(); + }); + + it("filters companies.list to the current invocation company", async () => { + const services = { + companies: { + list: vi.fn(async () => [ + { id: "company-a", name: "Company A" }, + { id: "company-b", name: "Company B" }, + ]), + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["companies.read"], + services, + }); + + await expect( + handlers["companies.list"]( + {}, + { invocationScope: { companyId: "company-a" } }, + ), + ).resolves.toEqual([{ id: "company-a", name: "Company A" }]); + }); + + it("rejects company-scope store access for a different company", async () => { + const stateGet = vi.fn(async () => null); + const services = { + state: { + get: stateGet, + }, + } as unknown as HostServices; + + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["plugin.state.read"], + services, + }); + + await expect( + handlers["state.get"]( + { scopeKind: "company", scopeId: "company-b", stateKey: "settings" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(stateGet).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "access.members.list", + "access.members.read", + { companyId: "company-a" }, + (services: HostServices) => vi.mocked(services.access.listMembers), + ], + [ + "access.members.update", + "access.members.write", + { companyId: "company-a", memberId: "member-a", patch: { status: "active" } }, + (services: HostServices) => vi.mocked(services.access.updateMember), + ], + [ + "authorization.grants.set", + "authorization.grants.write", + { companyId: "company-a", principalType: "agent", principalId: "agent-a", grants: [] }, + (services: HostServices) => vi.mocked(services.authorization.setGrants), + ], + [ + "authorization.policies.update", + "authorization.policies.write", + { companyId: "company-a", resourceType: "agent", resourceId: "agent-a", policy: null }, + (services: HostServices) => vi.mocked(services.authorization.updatePolicy), + ], + [ + "authorization.audit.search", + "authorization.audit.read", + { companyId: "company-a" }, + (services: HostServices) => vi.mocked(services.authorization.searchAudit), + ], + ] as const)( + "rejects %s when the plugin lacks %s", + async (method, capability, params, getDelegate) => { + const services = { + access: { + listMembers: vi.fn(async () => []), + updateMember: vi.fn(async () => ({ id: "member-a" })), + }, + authorization: { + setGrants: vi.fn(async () => []), + updatePolicy: vi.fn(async () => ({ policy: null })), + searchAudit: vi.fn(async () => []), + }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: [], + services, + }); + + await expect( + (handlers as Record Promise>)[method](params), + ).rejects.toMatchObject({ + name: "CapabilityDeniedError", + message: expect.stringContaining(capability), + }); + await expect( + (handlers as Record Promise>)[method](params), + ).rejects.toBeInstanceOf(CapabilityDeniedError); + expect(getDelegate(services)).not.toHaveBeenCalled(); + }, + ); + + it("checks invocation company scope before exposing authorization data", async () => { + const searchAudit = vi.fn(async () => []); + const services = { + authorization: { + searchAudit, + }, + } as unknown as HostServices; + const handlers = createHostClientHandlers({ + pluginId: "paperclip.test", + capabilities: ["authorization.audit.read"], + services, + }); + + await expect( + handlers["authorization.audit.search"]( + { companyId: "company-b" }, + { invocationScope: { companyId: "company-a" } }, + ), + ).rejects.toBeInstanceOf(InvocationScopeDeniedError); + expect(searchAudit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/sdk/tests/testing-actions.test.ts b/packages/plugins/sdk/tests/testing-actions.test.ts new file mode 100644 index 00000000..ab0026b2 --- /dev/null +++ b/packages/plugins/sdk/tests/testing-actions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { createTestHarness } from "../src/testing.js"; +import type { PaperclipPluginManifestV1 } from "../src/types.js"; + +const manifest = { + id: "paperclip.test-actions", + apiVersion: 1, + version: "1.0.0", + displayName: "Test Actions", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, +} satisfies PaperclipPluginManifestV1; + +describe("createTestHarness action context", () => { + it("passes immutable authenticated actor context and overrides caller company scope", async () => { + const harness = createTestHarness({ manifest }); + + harness.ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + contextFrozen: Object.isFrozen(context), + actorFrozen: Object.isFrozen(context.actor), + })); + + const result = await harness.performAction<{ + paramsCompanyId: unknown; + actor: { + type: string; + userId: string | null; + agentId: string | null; + runId: string | null; + companyId: string | null; + }; + companyId: string | null; + contextFrozen: boolean; + actorFrozen: boolean; + }>( + "inspect", + { companyId: "spoofed-company", value: true }, + { + companyId: "host-company", + actor: { + type: "user", + userId: "board-user-1", + runId: "run-1", + }, + }, + ); + + expect(result.paramsCompanyId).toBe("host-company"); + expect(result.companyId).toBe("host-company"); + expect(result.actor).toEqual({ + type: "user", + userId: "board-user-1", + agentId: null, + runId: "run-1", + companyId: "host-company", + }); + expect(result.contextFrozen).toBe(true); + expect(result.actorFrozen).toBe(true); + }); + + it("keeps existing one-argument action handlers compatible", async () => { + const harness = createTestHarness({ manifest }); + harness.ctx.actions.register("legacy", async (params) => ({ ok: params.ok })); + + await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index f8e0a38e..d41aa863 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -1,11 +1,26 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createInterface } from "node:readline"; +import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { isWorkerEntrypoint } from "../src/worker-rpc-host.js"; +import { definePlugin } from "../src/define-plugin.js"; +import { + createRequest, + createErrorResponse, + createSuccessResponse, + isJsonRpcRequest, + isJsonRpcResponse, + parseMessage, + PLUGIN_RPC_ERROR_CODES, + serializeMessage, + type JsonRpcResponse, + type PluginInvocationContext, +} from "../src/protocol.js"; +import { isWorkerEntrypoint, startWorkerRpcHost } from "../src/worker-rpc-host.js"; describe("isWorkerEntrypoint", () => { const tempRoots: string[] = []; @@ -55,3 +70,229 @@ describe("isWorkerEntrypoint", () => { ).toBe(false); }); }); + +describe("worker performAction context", () => { + it("does not derive context companyId from caller params without host actor context", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + const plugin = definePlugin({ + async setup(ctx) { + ctx.actions.register("inspect", async (params, context) => ({ + paramsCompanyId: params.companyId, + actor: context.actor, + companyId: context.companyId, + })); + }, + }); + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (!isJsonRpcResponse(message)) return; + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + }); + + try { + await expect(callWorker("initialize", { + manifest: { + id: "paperclip.test-worker-context", + apiVersion: 1, + version: "1.0.0", + displayName: "Worker Context Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + })).resolves.toMatchObject({ ok: true }); + + await expect(callWorker("performAction", { + key: "inspect", + params: { companyId: "spoofed-company" }, + })).resolves.toEqual({ + paramsCompanyId: "spoofed-company", + actor: { + type: "system", + userId: null, + agentId: null, + runId: null, + companyId: null, + }, + companyId: null, + }); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + }); +}); + +describe("worker invocation scope propagation", () => { + it("keeps overlapping company scopes local to each getData invocation", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + const nestedInvocationIds: string[] = []; + const invocationCompanies = new Map([ + ["invocation-a", "company-a"], + ["invocation-b", "company-b"], + ]); + let releaseCompanyA: (() => void) | null = null; + let nextRequestId = 1; + + const plugin = definePlugin({ + async setup(ctx) { + ctx.data.register("probe", async (params) => { + if (params.label === "a") { + await new Promise((resolve) => { + releaseCompanyA = resolve; + }); + } + const company = await ctx.companies.get(String(params.requestedCompanyId)); + return { label: params.label, company }; + }); + }, + }); + + const worker = startWorkerRpcHost({ + plugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown, invocation?: PluginInvocationContext) { + const id = `host-${nextRequestId++}`; + const request = { + ...createRequest(method, params, id), + ...(invocation ? { paperclipInvocation: invocation } : {}), + }; + const result = new Promise((resolve, reject) => { + pending.set(id, (response) => { + if ("error" in response && response.error) { + reject(new Error(response.error.message)); + return; + } + resolve((response as { result?: unknown }).result); + }); + }); + hostToWorker.write(serializeMessage(request)); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (isJsonRpcResponse(message)) { + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + return; + } + + if (!isJsonRpcRequest(message)) return; + if (message.method !== "companies.get") return; + + const invocationId = (message as { paperclipInvocationId?: string }).paperclipInvocationId ?? ""; + const requestedCompanyId = (message.params as { companyId?: string }).companyId; + const allowedCompanyId = invocationCompanies.get(invocationId); + nestedInvocationIds.push(invocationId); + if (requestedCompanyId !== allowedCompanyId) { + hostToWorker.write(serializeMessage(createErrorResponse( + message.id, + PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + `requested company "${requestedCompanyId}" but invocation "${invocationId}" is scoped to "${allowedCompanyId}"`, + ))); + return; + } + + hostToWorker.write(serializeMessage(createSuccessResponse(message.id, { + id: requestedCompanyId, + }))); + + if (invocationId === "invocation-b") { + releaseCompanyA?.(); + } + }); + + try { + await callWorker("initialize", { + manifest: { + id: "paperclip.scope-test", + apiVersion: 1, + version: "1.0.0", + displayName: "Scope test", + description: "Scope test", + author: "Paperclip", + categories: ["automation"], + capabilities: ["companies.read"], + entrypoints: { worker: "dist/worker.js" }, + }, + config: {}, + instanceInfo: { instanceId: "test", hostVersion: "0.0.0" }, + apiVersion: 1, + }); + + const companyARequest = callWorker( + "getData", + { + key: "probe", + companyId: "company-a", + params: { label: "a", requestedCompanyId: "company-b" }, + }, + { id: "invocation-a", scope: { companyId: "company-a" } }, + ); + const companyAExpectation = expect(companyARequest).rejects.toThrow( + /requested company "company-b"/, + ); + const companyBRequest = callWorker( + "getData", + { + key: "probe", + companyId: "company-b", + params: { label: "b", requestedCompanyId: "company-b" }, + }, + { id: "invocation-b", scope: { companyId: "company-b" } }, + ); + + await expect(companyBRequest).resolves.toEqual({ + label: "b", + company: { id: "company-b" }, + }); + await companyAExpectation; + + expect(nestedInvocationIds).toEqual(["invocation-b", "invocation-a"]); + } finally { + worker.stop(); + hostReadline.close(); + hostToWorker.destroy(); + workerToHost.destroy(); + } + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 38988c6f..db1ed959 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -12,11 +12,13 @@ export const API = { approvals: `${API_PREFIX}/approvals`, secrets: `${API_PREFIX}/secrets`, secretProviderConfigs: `${API_PREFIX}/secret-provider-configs`, + secretProviderConfigDiscoveryPreview: `${API_PREFIX}/companies/:companyId/secret-provider-configs/discovery/preview`, costs: `${API_PREFIX}/costs`, activity: `${API_PREFIX}/activity`, dashboard: `${API_PREFIX}/dashboard`, sidebarBadges: `${API_PREFIX}/sidebar-badges`, sidebarPreferences: `${API_PREFIX}/sidebar-preferences`, + resourceMemberships: `${API_PREFIX}/resource-memberships`, invites: `${API_PREFIX}/invites`, joinRequests: `${API_PREFIX}/join-requests`, members: `${API_PREFIX}/members`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c2f8f807..c102fa73 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -726,6 +726,7 @@ export const PLUGIN_CAPABILITIES = [ "companies.read", "projects.read", "project.workspaces.read", + "execution.workspaces.read", "issues.read", "issue.relations.read", "issue.subtree.read", @@ -738,6 +739,11 @@ export const PLUGIN_CAPABILITIES = [ "activity.read", "costs.read", "issues.orchestration.read", + "access.members.read", + "access.invites.read", + "authorization.grants.read", + "authorization.policies.read", + "authorization.audit.read", "database.namespace.read", // Data Write "issues.create", @@ -755,6 +761,10 @@ export const PLUGIN_CAPABILITIES = [ "agents.resume", "agents.invoke", "agents.managed", + "access.members.write", + "access.invites.write", + "authorization.grants.write", + "authorization.policies.write", "agent.sessions.create", "agent.sessions.list", "agent.sessions.send", @@ -856,6 +866,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "commentAnnotation", "commentContextMenuItem", "settingsPage", + "companySettingsPage", ] as const; export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; @@ -886,6 +897,21 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [ export type PluginReservedCompanyRouteSegment = (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number]; +/** + * Reserved route segments under `/:companyPrefix/company/settings/...` that + * plugin company settings pages may not claim. + */ +export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [ + "general", + "environments", + "access", + "members", + "invites", + "secrets", +] as const; +export type PluginReservedCompanySettingsRouteSegment = + (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]; + /** * Launcher placement zones describe where a plugin-owned launcher can appear * in the host UI. These are intentionally aligned with current slot surfaces @@ -961,6 +987,8 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [ "goal", "run", "comment", + "execution_workspace", + "project_workspace", ] as const; export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number]; @@ -1067,6 +1095,7 @@ export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number]; export const PLUGIN_BRIDGE_ERROR_CODES = [ "WORKER_UNAVAILABLE", "CAPABILITY_DENIED", + "INVOCATION_SCOPE_DENIED", "WORKER_ERROR", "TIMEOUT", "UNKNOWN", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6f3dfe35..6094e648 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -111,6 +111,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -226,6 +227,7 @@ export { type PluginCapability, type PluginUiSlotType, type PluginUiSlotEntityType, + type PluginReservedCompanySettingsRouteSegment, type PluginLauncherPlacementZone, type PluginLauncherAction, type PluginLauncherBounds, @@ -551,12 +553,18 @@ export type { CompanyPortabilityExportRequest, CompanyPortabilitySecretEntry, EnvBinding, + EnvPlainBinding, + EnvSecretRefBinding, AgentEnvConfig, CompanySecret, CompanySecretProviderConfig, SecretProviderConfigPayload, SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, + SecretProviderConfigDiscoveryCandidate, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderConfigDiscoverySample, + SecretProviderConfigDiscoverySignal, CompanySecretBinding, CompanySecretBindingTarget, CompanySecretUsageBinding, @@ -577,6 +585,7 @@ export type { SecretVersionSelector, SecretVersionStatus, Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, @@ -651,6 +660,18 @@ export { upsertSidebarOrderPreferenceSchema, type UpsertSidebarOrderPreference, } from "./validators/sidebar-preferences.js"; +export { + resourceMembershipStateSchema, + updateResourceMembershipSchema, + type UpdateResourceMembership, +} from "./validators/resource-memberships.js"; +export { + RESOURCE_MEMBERSHIP_STATES, + type ResourceMembershipResourceType, + type ResourceMembershipState, + type ResourceMemberships, + type ResourceMembershipUpdateResult, +} from "./types/resource-memberships.js"; export { workspaceRuntimeControlTargetSchema } from "./validators/execution-workspace.js"; export { @@ -680,6 +701,22 @@ export { MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "./types/instance.js"; +export type { + CloudUpstreamConnectStartResponse, + CloudUpstreamActivationDecision, + CloudUpstreamActivationEntityType, + CloudUpstreamConnection, + CloudUpstreamConflict, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamStep, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, +} from "./types/cloud-upstream.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, @@ -883,6 +920,7 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + secretProviderConfigDiscoveryPreviewSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, remoteSecretImportSelectionSchema, @@ -909,6 +947,7 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type SecretProviderConfigDiscoveryPreview, type RemoteSecretImportPreview, type RemoteSecretImport, type RemoteSecretImportSelection, @@ -1046,22 +1085,27 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + ROUTINE_MENTION_SCHEME, SKILL_MENTION_SCHEME, USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, + buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractRoutineMentionIds, extractSkillMentionIds, extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseRoutineMentionHref, parseSkillMentionHref, parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, + type ParsedRoutineMention, type ParsedSkillMention, type ParsedUserMention, } from "./project-mentions.js"; diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 709fd27d..1bb3bceb 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -2,14 +2,17 @@ import { describe, expect, it } from "vitest"; import { buildAgentMentionHref, buildProjectMentionHref, + buildRoutineMentionHref, buildSkillMentionHref, buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractRoutineMentionIds, extractSkillMentionIds, extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseRoutineMentionHref, parseSkillMentionHref, parseUserMentionHref, } from "./project-mentions.js"; @@ -49,4 +52,12 @@ describe("project-mentions", () => { }); expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); }); + + it("round-trips routine mentions", () => { + const href = buildRoutineMentionHref("routine-123"); + expect(parseRoutineMentionHref(href)).toEqual({ + routineId: "routine-123", + }); + expect(extractRoutineMentionIds(`[/routine:Weekly review](${href})`)).toEqual(["routine-123"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index b2d3ee93..5d618893 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -2,6 +2,7 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; +export const ROUTINE_MENTION_SCHEME = "routine://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; @@ -11,6 +12,7 @@ const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; +const ROUTINE_MENTION_LINK_RE = /\[[^\]]*]\((routine:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -33,6 +35,10 @@ export interface ParsedSkillMention { slug: string | null; } +export interface ParsedRoutineMention { + routineId: string; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -169,6 +175,28 @@ export function parseSkillMentionHref(href: string): ParsedSkillMention | null { }; } +export function buildRoutineMentionHref(routineId: string): string { + return `${ROUTINE_MENTION_SCHEME}${routineId.trim()}`; +} + +export function parseRoutineMentionHref(href: string): ParsedRoutineMention | null { + if (!href.startsWith(ROUTINE_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "routine:") return null; + + const routineId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!routineId) return null; + + return { routineId }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -217,6 +245,18 @@ export function extractSkillMentionIds(markdown: string): string[] { return [...ids]; } +export function extractRoutineMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(ROUTINE_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseRoutineMentionHref(match[1]); + if (parsed) ids.add(parsed.routineId); + } + return [...ids]; +} + function normalizeAgentIcon(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim().toLowerCase(); diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 18aea077..14c227dd 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -58,7 +58,7 @@ export interface AgentInstructionsBundle { export interface AgentAccessState { canAssignTasks: boolean; - taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none"; + taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none"; membership: CompanyMembership | null; grants: PrincipalPermissionGrant[]; } diff --git a/packages/shared/src/types/cloud-upstream.ts b/packages/shared/src/types/cloud-upstream.ts new file mode 100644 index 00000000..211fa25d --- /dev/null +++ b/packages/shared/src/types/cloud-upstream.ts @@ -0,0 +1,110 @@ +export type CloudUpstreamStep = "connect" | "scan" | "preview" | "push" | "verify" | "activate"; + +export type CloudUpstreamRunStatus = "previewed" | "running" | "succeeded" | "failed" | "cancelled"; + +export type CloudUpstreamActivationEntityType = "agents" | "routines" | "monitors"; + +export interface CloudUpstreamActivationDecision { + entityType: CloudUpstreamActivationEntityType; + count: number; + status: "paused" | "activated"; + activatedAt: string | null; +} + +export interface CloudUpstreamTarget { + stackId: string; + stackSlug: string | null; + stackDisplayName: string | null; + companyId: string; + primaryHost: string; + origin: string; + product: string; + schemaMajor: number; + maxChunkBytes: number; +} + +export interface CloudUpstreamConnection { + id: string; + companyId: string; + remoteUrl: string; + target: CloudUpstreamTarget; + tokenStatus: "pending" | "connected" | "expired" | "revoked"; + scopes: string[]; + authorizedGlobalUserId: string | null; + expiresAt: string | null; + createdAt: string; + updatedAt: string; + lastRunId: string | null; +} + +export interface CloudUpstreamSummaryCount { + key: string; + label: string; + count: number; +} + +export interface CloudUpstreamWarning { + code: string; + severity: "warning" | "blocker"; + title: string; + detail: string; +} + +export interface CloudUpstreamConflict { + id: string; + entityType: string; + sourceLabel: string; + targetLabel: string; + plannedAction: "create" | "update" | "skip" | "blocked"; + reason: string; +} + +export interface CloudUpstreamPreview { + connectionId: string; + sourceCompanyId: string; + target: CloudUpstreamTarget; + schemaCompatible: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + generatedAt: string; +} + +export interface CloudUpstreamRunEvent { + id: string; + at: string; + phase: CloudUpstreamStep; + type: "created" | "updated" | "skipped" | "conflict" | "retrying" | "failed" | "completed"; + message: string; +} + +export interface CloudUpstreamRun { + id: string; + connectionId: string; + companyId: string; + status: CloudUpstreamRunStatus; + activeStep: CloudUpstreamStep; + progressPercent: number; + dryRun: boolean; + summary: CloudUpstreamSummaryCount[]; + warnings: CloudUpstreamWarning[]; + conflicts: CloudUpstreamConflict[]; + events: CloudUpstreamRunEvent[]; + targetUrl: string | null; + report: Record; + retryOfRunId: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +} + +export interface CloudUpstreamsState { + connections: CloudUpstreamConnection[]; + runs: CloudUpstreamRun[]; +} + +export interface CloudUpstreamConnectStartResponse { + pendingConnectionId: string; + authorizationUrl: string; + connection: CloudUpstreamConnection; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5b80e35f..52cbb7c5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -258,6 +258,10 @@ export type { SecretProviderConfigPayload, SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, + SecretProviderConfigDiscoveryCandidate, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderConfigDiscoverySample, + SecretProviderConfigDiscoverySignal, CompanySecretBinding, CompanySecretBindingTarget, CompanySecretUsageBinding, @@ -279,6 +283,7 @@ export type { } from "./secrets.js"; export type { Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, @@ -322,6 +327,14 @@ export type { } from "./user-profile.js"; export type { SidebarBadges } from "./sidebar-badges.js"; export type { SidebarOrderPreference } from "./sidebar-preferences.js"; +export type { + ResourceMembershipResourceType, + ResourceMembershipState, + ResourceMemberships, + ResourceMembershipUpdateResult, + UpdateResourceMembership, +} from "./resource-memberships.js"; +export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js"; export type { InboxDismissal } from "./inbox-dismissal.js"; export type { AccessUserProfile, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ee6a6553..3be7c6fd 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -29,6 +29,7 @@ export interface InstanceGeneralSettings { export interface InstanceExperimentalSettings { enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; + enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7bbdde36..d822aa0f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -96,6 +96,9 @@ export interface IssueDocumentSummary { createdByUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; + lockedAt: Date | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 6f962912..f9330a48 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration { */ entityTypes?: PluginUiSlotEntityType[]; /** - * Optional company-scoped route segment for page and routeSidebar slots. + * Optional company-scoped route segment for page, routeSidebar, and + * companySettingsPage slots. * Example: `kitchensink` becomes `/:companyPrefix/kitchensink`. + * For companySettingsPage, `permissions` becomes + * `/:companyPrefix/company/settings/permissions`. */ routePath?: string; /** diff --git a/packages/shared/src/types/resource-memberships.ts b/packages/shared/src/types/resource-memberships.ts new file mode 100644 index 00000000..5830ed8f --- /dev/null +++ b/packages/shared/src/types/resource-memberships.ts @@ -0,0 +1,21 @@ +export const RESOURCE_MEMBERSHIP_STATES = ["joined", "left"] as const; + +export type ResourceMembershipState = (typeof RESOURCE_MEMBERSHIP_STATES)[number]; +export type ResourceMembershipResourceType = "project" | "agent"; + +export interface ResourceMemberships { + projectMemberships: Record; + agentMemberships: Record; + updatedAt: Date | null; +} + +export interface UpdateResourceMembership { + state: ResourceMembershipState; +} + +export interface ResourceMembershipUpdateResult { + resourceType: ResourceMembershipResourceType; + resourceId: string; + state: ResourceMembershipState; + updatedAt: Date; +} diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index c6f866dd..3e877153 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -8,6 +8,7 @@ import type { RoutineTriggerSigningMode, RoutineVariableType, } from "../constants.js"; +import type { EnvBinding } from "./secrets.js"; export interface RoutineProjectSummary { id: string; @@ -45,6 +46,8 @@ export interface RoutineVariable { options: string[]; } +export type RoutineEnvConfig = Record; + export interface Routine { id: string; companyId: string; @@ -59,6 +62,7 @@ export interface Routine { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env?: RoutineEnvConfig | null; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; @@ -98,6 +102,7 @@ export interface RoutineRevisionSnapshotRoutineV1 { concurrencyPolicy: RoutineConcurrencyPolicy; catchUpPolicy: RoutineCatchUpPolicy; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; } export interface RoutineRevisionSnapshotTriggerV1 { @@ -169,6 +174,7 @@ export interface RoutineRun { source: string; status: string; triggeredAt: Date; + routineRevisionId?: string | null; idempotencyKey: string | null; triggerPayload: Record | null; dispatchFingerprint: string | null; diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 7a4f0ae3..9c01ff40 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -138,6 +138,43 @@ export interface SecretProviderConfigHealthResponse { checkedAt: Date; } +export interface SecretProviderConfigDiscoverySignal { + namespace: string | null; + secretNamePrefix: string | null; + environmentTag: string | null; + ownerTag: string | null; + kmsKeyId: string | null; + hasKmsKey: boolean; + sampleCount: number; + paperclipManagedSampleCount: number; + skippedForeignPaperclipSampleCount: number; +} + +export interface SecretProviderConfigDiscoverySample { + name: string; + hasKmsKey: boolean; + tagKeys: string[]; +} + +export interface SecretProviderConfigDiscoveryCandidate { + provider: SecretProvider; + displayName: string; + config: SecretProviderConfigPayload; + sampleCount: number; + samples: SecretProviderConfigDiscoverySample[]; + signals: SecretProviderConfigDiscoverySignal; + warnings: string[]; +} + +export interface SecretProviderConfigDiscoveryPreviewResult { + provider: SecretProvider; + nextToken: string | null; + sampledSecretCount: number; + skippedForeignPaperclipSampleCount: number; + candidates: SecretProviderConfigDiscoveryCandidate[]; + warnings: string[]; +} + export interface CompanySecretVersion { id: string; secretId: string; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index c9466ca9..fe136776 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -168,7 +168,11 @@ export interface ExecutionWorkspaceSummary { id: string; name: string; mode: Exclude | "adapter_managed" | "cloud_sandbox"; + status: ExecutionWorkspaceStatus; + cwd: string | null; + branchName: string | null; projectWorkspaceId: string | null; + lastUsedAt: Date; } export interface ExecutionWorkspace { diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index bfb66751..6cbf8823 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -31,7 +31,7 @@ export const upsertAgentInstructionsFileSchema = z.object({ export type UpsertAgentInstructionsFile = z.infer; -const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { +const adapterConfigSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => { const envValue = value.env; if (envValue === undefined) return; const parsed = envConfigSchema.safeParse(envValue); @@ -46,7 +46,7 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { export const createAgentInstructionsBundleSchema = z.object({ entryFile: z.string().trim().min(1).optional(), - files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, { + files: z.record(z.string(), z.string()).refine((files) => Object.keys(files).length > 0, { message: "instructionsBundle.files must contain at least one file", }), }); @@ -78,7 +78,7 @@ export const createAgentSchema = z.object({ defaultEnvironmentId: z.string().uuid().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), permissions: agentPermissionsSchema.optional(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export type CreateAgent = z.infer; @@ -126,7 +126,7 @@ export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), reason: z.string().optional().nullable(), - payload: z.record(z.unknown()).optional().nullable(), + payload: z.record(z.string(), z.unknown()).optional().nullable(), idempotencyKey: z.string().optional().nullable(), forceFreshSession: z.preprocess( (value) => (value === null ? undefined : value), diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index ca704795..d24efadd 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -5,7 +5,7 @@ import { multilineTextSchema } from "./text.js"; export const createApprovalSchema = z.object({ type: z.enum(APPROVAL_TYPES), requestedByAgentId: z.string().uuid().optional().nullable(), - payload: z.record(z.unknown()), + payload: z.record(z.string(), z.unknown()), issueIds: z.array(z.string().uuid()).optional(), }); @@ -24,7 +24,7 @@ export const requestApprovalRevisionSchema = z.object({ export type RequestApprovalRevision = z.infer; export const resubmitApprovalSchema = z.object({ - payload: z.record(z.unknown()).optional(), + payload: z.record(z.string(), z.unknown()).optional(), }); export type ResubmitApproval = z.infer; diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index c9fd47b9..1022fa69 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -70,11 +70,11 @@ export const portabilityAgentManifestEntrySchema = z.object({ capabilities: z.string().nullable(), reportsToSlug: z.string().min(1).nullable(), adapterType: z.string().min(1), - adapterConfig: z.record(z.unknown()), - runtimeConfig: z.record(z.unknown()), - permissions: z.record(z.unknown()), + adapterConfig: z.record(z.string(), z.unknown()), + runtimeConfig: z.record(z.string(), z.unknown()), + permissions: z.record(z.string(), z.unknown()), budgetMonthlyCents: z.number().int().nonnegative(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilitySkillManifestEntrySchema = z.object({ @@ -88,7 +88,7 @@ export const portabilitySkillManifestEntrySchema = z.object({ sourceRef: z.string().nullable(), trustLevel: z.string().nullable(), compatibility: z.string().nullable(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), fileInventory: z.array(z.object({ path: z.string().min(1), kind: z.string().min(1), @@ -105,7 +105,7 @@ export const portabilityProjectManifestEntrySchema = z.object({ targetDate: z.string().nullable(), color: z.string().nullable(), status: z.string().nullable(), - executionWorkspacePolicy: z.record(z.unknown()).nullable(), + executionWorkspacePolicy: z.record(z.string(), z.unknown()).nullable(), workspaces: z.array(z.object({ key: z.string().min(1), name: z.string().min(1), @@ -116,10 +116,10 @@ export const portabilityProjectManifestEntrySchema = z.object({ visibility: z.string().nullable(), setupCommand: z.string().nullable(), cleanupCommand: z.string().nullable(), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), isPrimary: z.boolean(), })).default([]), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({ @@ -160,15 +160,15 @@ export const portabilityIssueManifestEntrySchema = z.object({ description: z.string().nullable(), recurring: z.boolean().default(false), routine: portabilityIssueRoutineManifestEntrySchema.nullable(), - legacyRecurrence: z.record(z.unknown()).nullable(), + legacyRecurrence: z.record(z.string(), z.unknown()).nullable(), status: z.string().nullable(), priority: z.string().nullable(), labelIds: z.array(z.string().min(1)).default([]), billingCode: z.string().nullable(), - executionWorkspaceSettings: z.record(z.unknown()).nullable(), - assigneeAdapterOverrides: z.record(z.unknown()).nullable(), + executionWorkspaceSettings: z.record(z.string(), z.unknown()).nullable(), + assigneeAdapterOverrides: z.record(z.string(), z.unknown()).nullable(), comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]), - metadata: z.record(z.unknown()).nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), }); export const portabilityManifestSchema = z.object({ @@ -207,7 +207,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("inline"), rootPath: z.string().min(1).optional().nullable(), - files: z.record(portabilityFileEntrySchema), + files: z.record(z.string(), portabilityFileEntrySchema), }), z.object({ type: z.literal("github"), @@ -262,7 +262,7 @@ export type CompanyPortabilityPreview = z.infer; @@ -37,7 +37,7 @@ export const probeEnvironmentConfigSchema = z.object({ name: z.string().min(1).optional(), description: z.string().optional().nullable(), driver: environmentDriverSchema, - config: z.record(z.unknown()).optional().default({}), - metadata: z.record(z.unknown()).optional().nullable(), + config: z.record(z.string(), z.unknown()).optional().default({}), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type ProbeEnvironmentConfig = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index b3633833..98a06155 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -13,7 +13,7 @@ export const executionWorkspaceConfigSchema = z.object({ provisionCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); @@ -94,7 +94,7 @@ export const workspaceRuntimeServiceSchema = z.object({ lastUsedAt: z.coerce.date(), startedAt: z.coerce.date(), stoppedAt: z.coerce.date().nullable(), - stopPolicy: z.record(z.unknown()).nullable(), + stopPolicy: z.record(z.string(), z.unknown()).nullable(), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), configIndex: z.number().int().nonnegative().nullable().optional(), createdAt: z.coerce.date(), @@ -125,7 +125,7 @@ export const updateExecutionWorkspaceSchema = z.object({ cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupReason: z.string().optional().nullable(), config: executionWorkspaceConfigSchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }).strict(); export type UpdateExecutionWorkspace = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 16a3eef6..676b51ed 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -51,6 +51,11 @@ export { upsertSidebarOrderPreferenceSchema, type UpsertSidebarOrderPreference, } from "./sidebar-preferences.js"; +export { + resourceMembershipStateSchema, + updateResourceMembershipSchema, + type UpdateResourceMembership, +} from "./resource-memberships.js"; export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, @@ -295,6 +300,7 @@ export { createSecretSchema, createSecretProviderConfigSchema, updateSecretProviderConfigSchema, + secretProviderConfigDiscoveryPreviewSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, remoteSecretImportSelectionSchema, @@ -311,6 +317,7 @@ export { type CreateSecret, type CreateSecretProviderConfig, type UpdateSecretProviderConfig, + type SecretProviderConfigDiscoveryPreview, type RemoteSecretImportPreview, type RemoteSecretImport, type RemoteSecretImportSelection, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3415539a..52da4d5f 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema. export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), + enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/packages/shared/src/validators/issue-tree-control.ts b/packages/shared/src/validators/issue-tree-control.ts index 48d78123..7144ef86 100644 --- a/packages/shared/src/validators/issue-tree-control.ts +++ b/packages/shared/src/validators/issue-tree-control.ts @@ -27,7 +27,7 @@ export const createIssueTreeHoldSchema = z mode: issueTreeControlModeSchema, reason: z.string().trim().min(1).max(1000).optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); @@ -37,7 +37,7 @@ export const releaseIssueTreeHoldSchema = z .object({ reason: z.string().trim().min(1).max(1000).optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 0d8374ec..8ade9897 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -73,6 +73,25 @@ describe("issue validators", () => { ).toBe(false); }); + it("allows restored recovery resolutions to return the source issue to todo", () => { + expect( + resolveIssueRecoveryActionSchema.parse({ + outcome: "restored", + sourceIssueStatus: "todo", + }), + ).toMatchObject({ + outcome: "restored", + sourceIssueStatus: "todo", + }); + + expect( + resolveIssueRecoveryActionSchema.safeParse({ + outcome: "false_positive", + sourceIssueStatus: "todo", + }).success, + ).toBe(false); + }); + it("allows cancelled recovery resolutions to atomically restore the source issue status", () => { expect( resolveIssueRecoveryActionSchema.parse({ diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b39fe7f8..e503ad2d 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -116,14 +116,14 @@ export const issueExecutionWorkspaceSettingsSchema = z mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); export const issueAssigneeAdapterOverridesSchema = z .object({ modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(), - adapterConfig: z.record(z.unknown()).optional(), + adapterConfig: z.record(z.string(), z.unknown()).optional(), useProjectWorkspace: z.boolean().optional(), }) .strict(); @@ -248,10 +248,10 @@ export const issueRecoveryActionReadModelSchema = z.object({ returnOwnerAgentId: z.string().uuid().nullable(), cause: z.string().min(1), fingerprint: z.string().min(1), - evidence: z.record(z.unknown()), + evidence: z.record(z.string(), z.unknown()), nextAction: z.string().min(1), - wakePolicy: z.record(z.unknown()).nullable(), - monitorPolicy: z.record(z.unknown()).nullable(), + wakePolicy: z.record(z.string(), z.unknown()).nullable(), + monitorPolicy: z.record(z.string(), z.unknown()).nullable(), attemptCount: z.number().int().nonnegative(), maxAttempts: z.number().int().positive().nullable(), timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(), @@ -275,14 +275,18 @@ const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [ export const resolveIssueRecoveryActionSchema = z.object({ actionId: z.string().uuid().optional(), outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES), - sourceIssueStatus: z.enum(["done", "in_review", "blocked"]), + sourceIssueStatus: z.enum(["todo", "done", "in_review", "blocked"]), resolutionNote: multilineTextSchema.optional().nullable(), }).strict().superRefine((value, ctx) => { if (value.outcome === "restored") { - if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") { + if ( + value.sourceIssueStatus !== "todo" && + value.sourceIssueStatus !== "done" && + value.sourceIssueStatus !== "in_review" + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Restored recovery actions must move the source issue to done or in_review", + message: "Restored recovery actions must move the source issue to todo, done, or in_review", path: ["sourceIssueStatus"], }); } diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index ccea0d6a..210c8845 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -8,6 +8,37 @@ describe("plugin capability constants", () => { }); }); +describe("plugin manifest validators", () => { + it("accepts existing-style plugins that do not request access or authorization capabilities", () => { + const parsed = pluginManifestV1Schema.parse({ + id: "paperclip.compat-dashboard", + apiVersion: 1, + version: "0.1.0", + displayName: "Compat Dashboard", + description: "Dashboard-only plugin without access or authorization host APIs.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.dashboardWidget.register"], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui.js", + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "compat-dashboard", + displayName: "Compat Dashboard", + exportName: "CompatDashboard", + }, + ], + }, + }); + + expect(parsed.capabilities).toEqual(["ui.dashboardWidget.register"]); + }); +}); + describe("plugin managed routine validators", () => { it("accepts core issue surface visibility values in routine templates", () => { const parsed = pluginManagedRoutineDeclarationSchema.parse({ @@ -104,4 +135,54 @@ describe("plugin UI slot validators", () => { if (parsed.success) return; expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); }); + + it("accepts workspace entity types as detailTab targets", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "detailTab", + id: "workspace-diff-viewer", + displayName: "Diff", + exportName: "WorkspaceDiffViewer", + entityTypes: ["execution_workspace", "project_workspace"], + }); + + expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]); + }); + + it("accepts execution_workspace as a toolbarButton entityType", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "toolbarButton", + id: "workspace-open-diff", + displayName: "Open diff", + exportName: "OpenWorkspaceDiffButton", + entityTypes: ["execution_workspace"], + }); + + expect(parsed.entityTypes).toEqual(["execution_workspace"]); + }); + + it("accepts company settings page slots with a non-core settings route", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "companySettingsPage", + id: "permissions-settings", + displayName: "Permissions", + exportName: "PermissionsSettingsPage", + routePath: "permissions", + }); + + expect(parsed.routePath).toBe("permissions"); + }); + + it("prevents company settings page slots from shadowing core settings routes", () => { + const parsed = pluginUiSlotDeclarationSchema.safeParse({ + type: "companySettingsPage", + id: "access-settings", + displayName: "Access", + exportName: "AccessSettingsPage", + routePath: "access", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); + }); }); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 8d4016cd..0a857b44 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -6,6 +6,7 @@ import { PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -39,7 +40,7 @@ import { routineVariableSchema } from "./routine.js"; * * @see PLUGIN_SPEC.md §10.1 — Manifest shape */ -export const jsonSchemaSchema = z.record(z.unknown()).refine( +export const jsonSchemaSchema = z.record(z.string(), z.unknown()).refine( (val) => { // Must have a "type" field if non-empty, or be a valid JSON Schema object if (Object.keys(val).length === 0) return true; @@ -143,9 +144,9 @@ export const pluginManagedAgentDeclarationSchema = z.object({ capabilities: z.string().max(2000).nullable().optional(), adapterType: z.string().min(1).max(100).optional(), adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(), - adapterConfig: z.record(z.unknown()).optional(), - runtimeConfig: z.record(z.unknown()).optional(), - permissions: z.record(z.unknown()).optional(), + adapterConfig: z.record(z.string(), z.unknown()).optional(), + runtimeConfig: z.record(z.string(), z.unknown()).optional(), + permissions: z.record(z.string(), z.unknown()).optional(), status: z.enum(["idle", "paused"]).optional(), budgetMonthlyCents: z.number().int().min(0).optional(), instructions: z.object({ @@ -166,7 +167,7 @@ export const pluginManagedProjectDeclarationSchema = z.object({ description: z.string().max(2000).nullable().optional(), status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(), color: z.string().max(32).nullable().optional(), - settings: z.record(z.unknown()).optional(), + settings: z.record(z.string(), z.unknown()).optional(), }); export type PluginManagedProjectDeclarationInput = z.infer; @@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["entityTypes"], }); } - if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") { + if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "routePath is only supported for page and routeSidebar slots", + message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots", path: ["routePath"], }); } @@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if (value.type === "companySettingsPage" && !value.routePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "companySettingsPage slots require routePath", + path: ["routePath"], + }); + } if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if ( + value.type === "companySettingsPage" + && value.routePath + && PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `company settings routePath "${value.routePath}" is reserved by the host`, + path: ["routePath"], + }); + } }); export type PluginUiSlotDeclarationInput = z.infer; @@ -373,7 +392,7 @@ const launcherBoundsByEnvironment: Record< export const pluginLauncherActionDeclarationSchema = z.object({ type: z.enum(PLUGIN_LAUNCHER_ACTIONS), target: z.string().min(1), - params: z.record(z.unknown()).optional(), + params: z.record(z.string(), z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.type === "performAction" && value.target.includes("/")) { ctx.addIssue({ @@ -993,7 +1012,7 @@ export type InstallPlugin = z.infer; * the plugin's instanceConfigSchema is done at the service layer. */ export const upsertPluginConfigSchema = z.object({ - configJson: z.record(z.unknown()), + configJson: z.record(z.string(), z.unknown()), }); export type UpsertPluginConfig = z.infer; @@ -1003,7 +1022,7 @@ export type UpsertPluginConfig = z.infer; * Allows a partial merge of config values. */ export const patchPluginConfigSchema = z.object({ - configJson: z.record(z.unknown()), + configJson: z.record(z.string(), z.unknown()), }); export type PatchPluginConfig = z.infer; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index b7f1aaa8..af831e41 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -21,16 +21,16 @@ export const projectExecutionWorkspacePolicySchema = z defaultProjectWorkspaceId: z.string().uuid().optional().nullable(), environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), - workspaceRuntime: z.record(z.unknown()).optional().nullable(), - branchPolicy: z.record(z.unknown()).optional().nullable(), - pullRequestPolicy: z.record(z.unknown()).optional().nullable(), - runtimePolicy: z.record(z.unknown()).optional().nullable(), - cleanupPolicy: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), + branchPolicy: z.record(z.string(), z.unknown()).optional().nullable(), + pullRequestPolicy: z.record(z.string(), z.unknown()).optional().nullable(), + runtimePolicy: z.record(z.string(), z.unknown()).optional().nullable(), + cleanupPolicy: z.record(z.string(), z.unknown()).optional().nullable(), }) .strict(); export const projectWorkspaceRuntimeConfigSchema = z.object({ - workspaceRuntime: z.record(z.unknown()).optional().nullable(), + workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), }).strict(); @@ -51,7 +51,7 @@ const projectWorkspaceFields = { remoteProvider: z.string().optional().nullable(), remoteWorkspaceRef: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), }; diff --git a/packages/shared/src/validators/resource-memberships.ts b/packages/shared/src/validators/resource-memberships.ts new file mode 100644 index 00000000..81cd4090 --- /dev/null +++ b/packages/shared/src/validators/resource-memberships.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { RESOURCE_MEMBERSHIP_STATES } from "../types/resource-memberships.js"; + +export const resourceMembershipStateSchema = z.enum(RESOURCE_MEMBERSHIP_STATES); + +export const updateResourceMembershipSchema = z.object({ + state: resourceMembershipStateSchema, +}); + +export type UpdateResourceMembership = z.infer; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index c3ae1b8a..2db48fbb 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -12,6 +12,7 @@ import { ISSUE_EXECUTION_WORKSPACE_PREFERENCES, issueExecutionWorkspaceSettingsSchema, } from "./issue.js"; +import { envConfigSchema } from "./secret.js"; const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]); @@ -60,6 +61,7 @@ export const createRoutineSchema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"), variables: z.array(routineVariableSchema).optional().default([]), + env: envConfigSchema.optional().nullable(), }); export type CreateRoutine = z.infer; @@ -83,6 +85,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), variables: z.array(routineVariableSchema), + env: envConfigSchema.nullable().default(null), }).strict(); export const routineRevisionSnapshotTriggerV1Schema = z.object({ @@ -143,8 +146,8 @@ export type UpdateRoutineTrigger = z.infer; export const runRoutineSchema = z.object({ triggerId: z.string().uuid().optional().nullable(), - payload: z.record(z.unknown()).optional().nullable(), - variables: z.record(routineVariableValueSchema).optional().nullable(), + payload: z.record(z.string(), z.unknown()).optional().nullable(), + variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(), projectId: z.string().uuid().optional().nullable(), assigneeAgentId: z.string().uuid().optional().nullable(), idempotencyKey: z.string().trim().max(255).optional().nullable(), diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index c8a8163d..10d81a9d 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -4,6 +4,7 @@ import { createSecretSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, + secretProviderConfigDiscoveryPreviewSchema, secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "./secret.js"; @@ -140,6 +141,40 @@ describe("secret validators", () => { }); }); + it("validates AWS provider vault discovery draft config without allowing sensitive keys", () => { + expect( + secretProviderConfigDiscoveryPreviewSchema.parse({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + namespace: "production", + secretNamePrefix: "paperclip", + }, + query: "paperclip", + pageSize: 50, + }), + ).toEqual({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + namespace: "production", + secretNamePrefix: "paperclip", + }, + query: "paperclip", + pageSize: 50, + }); + + expect(() => + secretProviderConfigDiscoveryPreviewSchema.parse({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + accessKeyId: "AKIA...", + }, + }), + ).toThrow(/sensitive field/i); + }); + it("caps AWS remote import paging and row counts", () => { expect(() => remoteSecretImportPreviewSchema.parse({ diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index ae364617..a90bf064 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -25,7 +25,7 @@ export const envBindingSchema = z.union([ envBindingSecretRefSchema, ]); -export const envConfigSchema = z.record(envBindingSchema); +export const envConfigSchema = z.record(z.string(), envBindingSchema); export const createSecretSchema = z.object({ name: z.string().min(1), @@ -36,7 +36,7 @@ export const createSecretSchema = z.object({ value: z.string().min(1).optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), providerVersionRef: z.string().optional().nullable(), }).superRefine((value, ctx) => { if ((value.managedMode ?? "paperclip_managed") === "external_reference") { @@ -83,7 +83,7 @@ export const updateSecretSchema = z.object({ providerConfigId: z.string().uuid().optional().nullable(), description: z.string().optional().nullable(), externalRef: z.string().optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export type UpdateSecret = z.infer; @@ -198,7 +198,7 @@ export const createSecretProviderConfigSchema = z.object({ displayName: z.string().trim().min(1).max(120), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), isDefault: z.boolean().optional(), - config: z.record(z.unknown()).default({}), + config: z.record(z.string(), z.unknown()).default({}), }).superRefine((value, ctx) => { rejectSensitiveProviderConfigKeys(value.config, ctx); const parsed = secretProviderConfigPayloadSchema.safeParse({ @@ -236,7 +236,7 @@ export const updateSecretProviderConfigSchema = z.object({ displayName: z.string().trim().min(1).max(120).optional(), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), isDefault: z.boolean().optional(), - config: z.record(z.unknown()).optional(), + config: z.record(z.string(), z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.config !== undefined) { rejectSensitiveProviderConfigKeys(value.config, ctx); @@ -262,13 +262,37 @@ export const remoteSecretImportPreviewSchema = z.object({ export type RemoteSecretImportPreview = z.infer; +export const secretProviderConfigDiscoveryPreviewSchema = z.object({ + provider: z.enum(SECRET_PROVIDERS), + config: z.record(z.unknown()).default({}), + query: z.string().trim().max(200).optional().nullable(), + nextToken: z.string().trim().min(1).max(4096).optional().nullable(), + pageSize: z.number().int().min(1).max(100).optional(), +}).superRefine((value, ctx) => { + rejectSensitiveProviderConfigKeys(value.config, ctx); + const parsed = secretProviderConfigPayloadSchema.safeParse({ + provider: value.provider, + config: value.config, + }); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ + ...issue, + path: issue.path[0] === "config" ? issue.path : ["config", ...issue.path], + }); + } + } +}); + +export type SecretProviderConfigDiscoveryPreview = z.infer; + export const remoteSecretImportSelectionSchema = z.object({ externalRef: z.string().trim().min(1).max(2048), name: z.string().trim().min(1).max(160).optional().nullable(), key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(), description: z.string().trim().max(500).optional().nullable(), providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(), - providerMetadata: z.record(z.unknown()).optional().nullable(), + providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(), }); export const remoteSecretImportSchema = z.object({ diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index 839cc15a..b068b9c9 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -43,7 +43,7 @@ export const createIssueWorkProductSchema = z.object({ isPrimary: z.boolean().optional().default(false), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"), summary: z.string().optional().nullable(), - metadata: z.record(z.unknown()).optional().nullable(), + metadata: z.record(z.string(), z.unknown()).optional().nullable(), createdByRunId: z.string().uuid().optional().nullable(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbacb527..27725ab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -219,6 +222,22 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/grok-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -537,6 +556,40 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + packages/plugins/plugin-workspace-diff: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../sdk + '@pierre/diffs': + specifier: ^1.1.22 + version: 1.1.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.3(@types/react@19.2.14) + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + packages/plugins/sdk: dependencies: '@paperclipai/shared': @@ -592,6 +645,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -646,15 +702,9 @@ importers: hermes-paperclip-adapter: specifier: ^0.2.0 version: 0.2.0 - isomorphic-git: - specifier: ^1.38.0 - version: 1.38.0 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) - memfs: - specifier: ^4.57.2 - version: 4.57.2(tslib@2.8.1) multer: specifier: ^2.1.1 version: 2.1.1 @@ -761,6 +811,9 @@ importers: '@paperclipai/adapter-gemini-local': specifier: workspace:* version: link:../packages/adapters/gemini-local + '@paperclipai/adapter-grok-local': + specifier: workspace:* + version: link:../packages/adapters/grok-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -797,6 +850,9 @@ importers: hermes-paperclip-adapter: specifier: ^0.2.0 version: 0.2.0 + i18next: + specifier: ^26.1.0 + version: 26.2.0(typescript@5.9.3) lexical: specifier: 0.35.0 version: 0.35.0 @@ -815,6 +871,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^17.0.7 + version: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -1253,6 +1312,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -2252,126 +2315,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsonjoy.com/base64@1.1.2': - resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/base64@17.67.0': - resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/buffers@1.2.1': - resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/buffers@17.67.0': - resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/codegen@1.0.0': - resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/codegen@17.67.0': - resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-core@4.57.2': - resolution: {integrity: sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-fsa@4.57.2': - resolution: {integrity: sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-builtins@4.57.2': - resolution: {integrity: sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-to-fsa@4.57.2': - resolution: {integrity: sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node-utils@4.57.2': - resolution: {integrity: sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-node@4.57.2': - resolution: {integrity: sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-print@4.57.2': - resolution: {integrity: sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/fs-snapshot@4.57.2': - resolution: {integrity: sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pack@1.21.0': - resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pack@17.67.0': - resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pointer@1.0.2': - resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pointer@17.67.0': - resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/util@1.9.0': - resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/util@17.67.0': - resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - '@lexical/clipboard@0.35.0': resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==} @@ -2559,6 +2502,16 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pierre/diffs@1.1.22': + resolution: {integrity: sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + + '@pierre/theme@0.0.28': + resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==} + engines: {vscode: ^1.0.0} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -3465,6 +3418,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/abort-controller@4.2.8': resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} @@ -4193,10 +4170,6 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4304,9 +4277,6 @@ packages: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} - async-lock@1.4.1: - resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -4314,10 +4284,6 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - axe-core@4.11.3: resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} engines: {node: '>=4'} @@ -4519,10 +4485,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -4578,9 +4540,6 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - clean-git-ref@2.0.1: - resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} - clean-set@1.1.2: resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==} @@ -4694,11 +4653,6 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -4939,10 +4893,6 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -4986,13 +4936,14 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff3@0.0.3: - resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} - diff@5.2.2: resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -5258,17 +5209,9 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -5348,10 +5291,6 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -5426,12 +5365,6 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-to-regex.js@1.2.0: - resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -5450,9 +5383,6 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -5468,6 +5398,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -5489,9 +5422,15 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -5518,9 +5457,13 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - hyperdyperid@1.2.0: - resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} - engines: {node: '>=10.18'} + i18next@26.2.0: + resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} @@ -5533,10 +5476,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5590,10 +5529,6 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -5638,25 +5573,13 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-git@1.38.0: - resolution: {integrity: sha512-gsBFnAT8Fxrpx+53ymG5kEOHSrUDVcSMFl7fCEGVnPpQbPS0aKti3UzZXR+3DKA0yyf+4z6CXJxULlQ5QPxDJw==} - engines: {node: '>=14.17'} - hasBin: true - isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -5837,6 +5760,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + lucide-react@0.574.0: resolution: {integrity: sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==} peerDependencies: @@ -5933,11 +5859,6 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - memfs@4.57.2: - resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==} - peerDependencies: - tslib: '2' - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6102,9 +6023,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minimisted@2.0.1: - resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} - minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} @@ -6235,6 +6153,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -6253,9 +6177,6 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -6338,10 +6259,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} @@ -6385,10 +6302,6 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -6438,10 +6351,6 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} - promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} peerDependencies: @@ -6532,6 +6441,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6609,10 +6534,6 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6629,6 +6550,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -6734,18 +6664,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} - hasBin: true - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6758,6 +6679,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -6967,12 +6891,6 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - thingies@2.6.0: - resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} - engines: {node: '>=10.18'} - peerDependencies: - tslib: ^2 - thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -7012,10 +6930,6 @@ packages: resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} hasBin: true - to-buffer@1.2.2: - resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} - engines: {node: '>= 0.4'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -7028,12 +6942,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - tree-dump@1.1.0: - resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -7073,10 +6981,6 @@ packages: type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -7341,6 +7245,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -7383,10 +7291,6 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -8206,6 +8110,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -9121,133 +9027,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/fs-core@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-fsa@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-builtins@4.57.2(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-to-fsa@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node-utils@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-node@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-print@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/fs-snapshot@4.57.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 2.6.0(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) - '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) - '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) - tslib: 2.8.1 - '@lexical/clipboard@0.35.0': dependencies: '@lexical/html': 0.35.0 @@ -9620,6 +9399,19 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pierre/diffs@1.1.22(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@pierre/theme': 0.0.28 + '@shikijs/transformers': 3.23.0 + diff: 8.0.3 + hast-util-to-html: 9.0.5 + lru_map: 0.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + shiki: 3.23.0 + + '@pierre/theme@0.0.28': {} + '@pinojs/redact@0.4.0': {} '@playwright/test@1.58.2': @@ -10515,6 +10307,44 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/abort-controller@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -11038,7 +10868,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -11434,10 +11264,6 @@ snapshots: abbrev@1.1.1: optional: true - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -11543,16 +11369,10 @@ snapshots: async-exit-hook@2.0.1: {} - async-lock@1.4.1: {} - asynckit@0.4.0: {} atomic-sleep@1.0.0: {} - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - axe-core@4.11.3: {} b4a@1.8.1: {} @@ -11732,13 +11552,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -11794,8 +11607,6 @@ snapshots: classnames@2.5.1: {} - clean-git-ref@2.0.1: {} - clean-set@1.1.2: {} clean-stack@2.2.0: @@ -11897,8 +11708,6 @@ snapshots: dependencies: layout-base: 2.0.1 - crc-32@1.2.2: {} - crelt@1.0.6: {} cross-env@10.1.0: @@ -12157,12 +11966,6 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - define-lazy-prop@3.0.0: {} defu@6.1.4: {} @@ -12197,10 +12000,10 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff3@0.0.3: {} - diff@5.2.2: {} + diff@8.0.3: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -12462,16 +12265,12 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 - event-target-shim@5.0.1: {} - events-universal@1.0.1: dependencies: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller - events@3.3.0: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -12571,10 +12370,6 @@ snapshots: transitivePeerDependencies: - supports-color - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -12654,10 +12449,6 @@ snapshots: github-from-package@0.0.0: {} - glob-to-regex.js@1.2.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -12680,10 +12471,6 @@ snapshots: hachure-fill@0.5.2: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -12697,6 +12484,20 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -12736,8 +12537,14 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: optional: true @@ -12785,7 +12592,9 @@ snapshots: ms: 2.1.3 optional: true - hyperdyperid@1.2.0: {} + i18next@26.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 iconv-lite@0.6.3: dependencies: @@ -12797,8 +12606,6 @@ snapshots: ieee754@1.2.1: {} - ignore@5.3.2: {} - imurmurhash@0.1.4: optional: true @@ -12839,8 +12646,6 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-callable@1.2.7: {} - is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -12871,32 +12676,12 @@ snapshots: is-promise@4.0.0: {} - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 - isarray@2.0.5: {} - isexe@2.0.0: {} - isomorphic-git@1.38.0: - dependencies: - async-lock: 1.4.1 - clean-git-ref: 2.0.1 - crc-32: 1.2.2 - diff3: 0.0.3 - ignore: 5.3.2 - minimisted: 2.0.1 - pako: 1.0.11 - pify: 4.0.1 - readable-stream: 4.7.0 - sha.js: 2.4.12 - simple-get: 4.0.1 - isomorphic.js@0.2.5: {} jiti@2.6.1: {} @@ -13051,6 +12836,8 @@ snapshots: yallist: 4.0.0 optional: true + lru_map@0.4.1: {} + lucide-react@0.574.0(react@19.2.4): dependencies: react: 19.2.4 @@ -13288,23 +13075,6 @@ snapshots: media-typer@1.1.0: {} - memfs@4.57.2(tslib@2.8.1): - dependencies: - '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-to-fsa': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) - '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) - '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) - glob-to-regex.js: 1.2.0(tslib@2.8.1) - thingies: 2.6.0(tslib@2.8.1) - tree-dump: 1.1.0(tslib@2.8.1) - tslib: 2.8.1 - merge-descriptors@2.0.0: {} mermaid@11.12.3: @@ -13651,10 +13421,6 @@ snapshots: minimist@1.2.8: {} - minimisted@2.0.1: - dependencies: - minimist: 1.2.8 - minipass-collect@1.0.2: dependencies: minipass: 3.3.6 @@ -13786,6 +13552,14 @@ snapshots: dependencies: wrappy: 1.0.2 + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -13811,8 +13585,6 @@ snapshots: package-manager-detector@1.6.0: {} - pako@1.0.11: {} - parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -13892,8 +13664,6 @@ snapshots: picomatch@4.0.3: {} - pify@4.0.1: {} - pino-abstract-transport@2.0.0: dependencies: split2: 4.2.0 @@ -13964,8 +13734,6 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - possible-typed-array-names@1.1.0: {} - postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -14016,8 +13784,6 @@ snapshots: process-warning@5.0.0: {} - process@0.11.10: {} - promise-inflight@1.0.1: optional: true @@ -14169,6 +13935,17 @@ snapshots: dependencies: react: 19.2.4 + react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.2.0(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -14251,14 +14028,6 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readable-stream@4.7.0: - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - string_decoder: 1.3.0 - readdirp@4.1.2: {} real-require@0.2.0: {} @@ -14276,6 +14045,16 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -14436,23 +14215,8 @@ snapshots: set-cookie-parser@2.7.2: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - setprototypeof@1.2.0: {} - sha.js@2.4.12: - dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.2 - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -14490,6 +14254,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -14775,10 +14550,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - thingies@2.6.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -14808,12 +14579,6 @@ snapshots: dependencies: tldts-core: 7.0.26 - to-buffer@1.2.2: - dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 - toidentifier@1.0.1: {} tough-cookie@6.0.1: @@ -14824,10 +14589,6 @@ snapshots: dependencies: punycode: 2.3.1 - tree-dump@1.1.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -14868,12 +14629,6 @@ snapshots: type@2.7.3: {} - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - typedarray@0.0.6: {} typescript@5.9.3: {} @@ -15211,6 +14966,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -15248,16 +15005,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/releases/v2026.517.0.md b/releases/v2026.517.0.md new file mode 100644 index 00000000..5ec09546 --- /dev/null +++ b/releases/v2026.517.0.md @@ -0,0 +1,30 @@ +# v2026.517.0 + +> Released: 2026-05-17 + +## Highlights + +- **Grok Build is now a first-class local runtime** - Paperclip can configure and run `grok_local` through the built-in adapter registry, with server, UI, CLI, transcript parsing, session handling, diagnostics, and focused adapter coverage. ([#6087](https://github.com/paperclipai/paperclip/pull/6087), @devinfoley) +- **Issue documents can be locked for safer handoffs** - Board-managed document locks preserve approved snapshots, route agent writes to derived documents, expose lock state in the UI and API, and record lock activity. ([#6009](https://github.com/paperclipai/paperclip/pull/6009), @cryppadotta) +- **The Issues board scales better with large columns** - High-volume Kanban columns now support compact cards, collapsed cold lanes, per-column reveal limits, persisted density controls, and matching UI coverage. ([#5309](https://github.com/paperclipai/paperclip/pull/5309), @eibrahim) +- **Internationalization groundwork is in place** - The board UI now has an i18next foundation, locale validation, runtime package declarations, and a validated catalog of supported locale resources for future translated surfaces. ([#6058](https://github.com/paperclipai/paperclip/pull/6058), [#5943](https://github.com/paperclipai/paperclip/pull/5943), [#6070](https://github.com/paperclipai/paperclip/pull/6070), @cryppadotta) + +## Improvements + +- **Live issue document updates refresh open boards** - Document create, update, restore, and delete activity now invalidates the relevant list, active-document, and revision caches without forcing operators to reload the issue page. ([#6005](https://github.com/paperclipai/paperclip/pull/6005), @cryppadotta) +- **Cloudflare sandbox execution is more reliable** - The Cloudflare bridge now has larger execution capacity, SSE keepalives, safer stdout handling, sandbox-aware probe budgets, and a corrected Pi sandbox install path. ([#5967](https://github.com/paperclipai/paperclip/pull/5967), @devinfoley) +- **LLM Wiki packaging and plugin migrations are safer** - The packaged plugin now includes required assets, uses a visible bootstrap template, and validates and applies spaces migrations through explicit namespace-safe SQL. ([#6010](https://github.com/paperclipai/paperclip/pull/6010), @cryppadotta) +- **PR verification runs faster** - The GitHub PR workflow now fans out typecheck, release-registry checks, grouped general tests, and build work instead of serializing them behind one monolithic verify job. ([#6137](https://github.com/paperclipai/paperclip/pull/6137), @devinfoley) +- **Multilingual issue flows have regression coverage** - Board issue creation, server issue/comment/document round trips, and scoped wake payload rendering now cover Chinese, Japanese, and Hindi text preservation. ([#6069](https://github.com/paperclipai/paperclip/pull/6069), @cryppadotta) + +## Fixes + +- **Grok reasoning streams regain readable turn boundaries** - Paperclip now restores missing line breaks between streamed Grok reasoning chunks so the live Working panel no longer merges separate thoughts into run-on text. ([#6142](https://github.com/paperclipai/paperclip/pull/6142), @devinfoley) +- **Identifier-based wakeups bind to the right project workspace** - Heartbeat wakeup context now carries the resolved `projectId` and canonicalizes identifier-style issue references before workspace resolution. ([#6026](https://github.com/paperclipai/paperclip/pull/6026), @devinfoley) +- **Company export tolerates missing local run logs** - Optional comment attribution metadata now degrades gracefully when historical heartbeat run log files are missing, preserving exportable issue comments. ([#5960](https://github.com/paperclipai/paperclip/pull/5960), @cryppadotta) + +## Contributors + +Thank you to everyone who contributed to this release! + +@eibrahim diff --git a/releases/v2026.525.0.md b/releases/v2026.525.0.md new file mode 100644 index 00000000..5917a046 --- /dev/null +++ b/releases/v2026.525.0.md @@ -0,0 +1,44 @@ +# v2026.525.0 + +> Released: 2026-05-25 + +## Highlights + +- **Modal sandbox provider is now a first-party plugin** - Paperclip ships a Modal sandbox-provider plugin so companies can run agents on Modal's managed sandboxes alongside E2B, Cloudflare, and Daytona, with CI publishing and cold-start-friendly probe timeouts wired in. ([#6245](https://github.com/paperclipai/paperclip/pull/6245), [#6289](https://github.com/paperclipai/paperclip/pull/6289), [#6290](https://github.com/paperclipai/paperclip/pull/6290), @devinfoley) +- **Workspace diffs are a first-class viewer plugin** - The new workspace diff plugin renders staged, unstaged, head, renamed, binary, oversized, and untracked changes through host services and plugin slots, with split/unified panes, sticky headers, and reliable default base refs. ([#6071](https://github.com/paperclipai/paperclip/pull/6071), [#6383](https://github.com/paperclipai/paperclip/pull/6383), @cryppadotta) +- **Routines can carry their own secrets** - Routine env now flows through the runtime contract with persisted revisions, `agent < project < routine` precedence, and safe secret metadata in routine UI/history — without exposing secret values in logs or access events. ([#6212](https://github.com/paperclipai/paperclip/pull/6212), @cryppadotta) +- **Local Cloud Upstream sync is in** - A new Cloud Upstream flow ships with shared types, server routes, persisted run schema, CLI sync helpers, a board UI, and settings entry points so operators can preview, resolve conflicts, and activate local-to-cloud syncs. ([#6548](https://github.com/paperclipai/paperclip/pull/6548), @cryppadotta) +- **ACPX-Claude adapter works seamlessly out of the box** - The `acpx_local` adapter now resolves bare Claude model IDs, surfaces real diagnostic detail instead of opaque "Internal error" logs, and respects user `~/.claude/settings.json` permissions so first-run Claude Local ACPX agents don't strand. ([#6590](https://github.com/paperclipai/paperclip/pull/6590), @devinfoley) + +## Improvements + +- **Scoped agent permissions and assignment controls** - Issue and agent assignment mutations now run through a real authorization service with protected-assignment enforcement, plugin SDK/host APIs for company settings slots and policy/grant management, retry-now affordances on blocked issues, and an incremental principal-access compatibility backfill. ([#6386](https://github.com/paperclipai/paperclip/pull/6386), @cryppadotta) +- **AWS provider vault setup is operator-friendly** - The Secrets page now offers AWS provider vault discovery with prefill, removal flows, and Storybook coverage so external vault configuration no longer requires hand-typed metadata. ([#6381](https://github.com/paperclipai/paperclip/pull/6381), @cryppadotta) +- **SecretBindingPicker is wired into plugin config forms** - JSON-schema secret-ref fields (E2B, Modal, Cloudflare, Daytona, …) now render the canonical secret picker instead of a plain password input, so binding stored secrets no longer requires copy-pasting UUIDs. ([#6339](https://github.com/paperclipai/paperclip/pull/6339), @devinfoley) +- **External agent invites moved into the add-agent modal** - Bring-your-own-agent onboarding now lives next to local/managed agent creation with an agent-oriented prompt result view and Back navigation, instead of hiding behind OpenClaw-specific company invite settings. ([#6183](https://github.com/paperclipai/paperclip/pull/6183), @aronprins) +- **Mobile board flows feel smoother** - Mobile new-issue dialog height, priority overflow, company settings nav, plugin-route sidebar selection, browser controls in home-screen app mode, and small touch-target/menu scroll bugs are all polished. ([#6550](https://github.com/paperclipai/paperclip/pull/6550), [#6384](https://github.com/paperclipai/paperclip/pull/6384), @cryppadotta) +- **Plugin runtime is scoped tighter to its company** - Plugin worker-to-host calls now propagate host-owned invocation context, `performAction` carries authenticated actor context, company invocation scope is enforced on bridge calls, and plugin operation issues stay out of normal issue surfaces. ([#6547](https://github.com/paperclipai/paperclip/pull/6547), @cryppadotta) +- **Runtime and tenant import paths are more reliable** - Embedded Postgres now bootstraps native runtime before CLI/server/test startup, async tenant import jobs have deferred validation, and trusted Cloud tenant imports no longer fail the browser-origin guard for legitimate server-to-server traffic. ([#6549](https://github.com/paperclipai/paperclip/pull/6549), [#6378](https://github.com/paperclipai/paperclip/pull/6378), @cryppadotta) +- **Control-plane state transitions are tighter** - Pagination sorts cleanly on updated issue lists, scheduled retry comments behave, pending plugin migrations re-apply on hot reload, plugin-schema worktree seeds restore safely, stale request confirmations expire after user comments, and feedback export shutdown drains without database-unavailable loops. ([#6380](https://github.com/paperclipai/paperclip/pull/6380), @cryppadotta) +- **Cheap recovery model is fenced off from real work** - Status-only recovery now carries explicit guard context, route guards block deliverable mutations during cheap runs, and cheap-profile hints no longer leak into normal source-work retries. ([#6371](https://github.com/paperclipai/paperclip/pull/6371), @cryppadotta) +- **Invite flow, projects, and workspace polish from the May 17 branch land** - Invite landing reuses the shared companies query helper, existing-member invite behavior and copy fallback are restored, reusable workspace selection picks correctly, worktree auth and static SPA fallback are fixed, markdown wrapping and plugin slot registration are firmed up, and projects page sorting lands. ([#6604](https://github.com/paperclipai/paperclip/pull/6604), [#6210](https://github.com/paperclipai/paperclip/pull/6210), @cryppadotta) +- **Sandbox-provider plugins no longer clutter Instance Settings** - Driver-only plugins (E2B, exe.dev, Modal) are hidden from the per-plugin sidebar group since they have no own settings page and already redirect to Environments. ([#6341](https://github.com/paperclipai/paperclip/pull/6341), @devinfoley) +- **Inbox rows are cleaner** - The amber "Planning" pill is removed from `IssueRow`; planning mode itself, the composer toggle, and the work-mode contract are untouched. ([#6269](https://github.com/paperclipai/paperclip/pull/6269), @cryppadotta) +- **Plugin authoring guide reflects managed resources** - The plugin authoring docs are updated for the current managed-capabilities model so plugin authors aren't writing against stale guidance. ([#6261](https://github.com/paperclipai/paperclip/pull/6261), @cryppadotta) + +## Fixes + +- **E2B heartbeats stop failing at 5/11 minutes** - The workspace tar upload now strips macOS `LIBARCHIVE.xattr.*` PAX headers that GNU tar rejected on Linux, and `timeoutMs`/`sleepAfter` are refreshed per execute so E2B and Cloudflare sandboxes don't expire between heartbeats. ([#6560](https://github.com/paperclipai/paperclip/pull/6560), @devinfoley) +- **Invite page no longer goes blank after sign-in** - `CompanyProvider` and the invite landing page now share a single companies query shape, fixing the `companiesQuery.data?.some is not a function` crash from the React Query key-key collision. ([#6433](https://github.com/paperclipai/paperclip/pull/6433), @aronprins) +- **Company creation survives wrapped prefix collisions** - The retry detector now walks the Drizzle 0.45.x error cause chain for the `companies_issue_prefix_idx` unique constraint, so generated-prefix collisions retry instead of 500-ing. ([#6423](https://github.com/paperclipai/paperclip/pull/6423), @aronprins) +- **Autocomplete works inside the new-issue dialog** - Floating autocomplete menus rendered through body-level portals are now marked as allowed dialog-external UI, so completion items are selectable inside Radix Dialog without pointer events being eaten. ([#6311](https://github.com/paperclipai/paperclip/pull/6311), @cryppadotta) +- **`paperclip worktree init --force` no longer wipes `/.paperclip/worktrees/`** - The init path stopped recursively removing the whole `/.paperclip/` directory and now only rewrites `config.json` and `.env`. ([#6240](https://github.com/paperclipai/paperclip/pull/6240), @devinfoley) +- **New secret form stays usable with long values** - The shared `Textarea` primitive now applies `min-w-0 max-w-full`, so a long unbreakable secret no longer pushes the Create/Cancel buttons off-screen. ([#6222](https://github.com/paperclipai/paperclip/pull/6222), @devinfoley) +- **Cold-start sandbox probes no longer time out** - `environmentProbe` worker RPC timeout is raised to 120s so Modal (and other cold-start providers) finish booting before the probe fails. ([#6289](https://github.com/paperclipai/paperclip/pull/6289), @devinfoley) +- **Docker builds find `link-plugin-dev-sdk.mjs`** - The Dockerfile `deps` stage now copies the script before `pnpm install`, so the `plugin-workspace-diff` postinstall hook doesn't fail with `Cannot find module`. ([#6338](https://github.com/paperclipai/paperclip/pull/6338), @devinfoley) + +## Contributors + +Thank you to everyone who contributed to this release! + +@aronprins diff --git a/screenshots/PAP-9841-workspace-diff.png b/screenshots/PAP-9841-workspace-diff.png new file mode 100644 index 00000000..dcd0e7e0 Binary files /dev/null and b/screenshots/PAP-9841-workspace-diff.png differ diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs index 48deb739..32270f6b 100644 --- a/scripts/bootstrap-npm-package.test.mjs +++ b/scripts/bootstrap-npm-package.test.mjs @@ -52,3 +52,9 @@ test("resolveTargetPackage matches by package name or dir", () => { assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a"); assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b"); }); + +test("resolveTargetPackage includes the workspace diff plugin bootstrap package", () => { + const pkg = resolveTargetPackage("@paperclipai/plugin-workspace-diff"); + + assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff"); +}); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index e5d73794..8516a9c5 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -36,6 +36,7 @@ const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json"); const devServerStatusToken = mode === "dev" ? randomUUID() : null; const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; @@ -70,6 +71,7 @@ const ignoredDirectoryNames = new Set([ ]); const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-restart-request.json", ".paperclip/dev-server-status.json", ]); @@ -348,6 +350,13 @@ function writeDevServerStatus() { function clearDevServerStatus() { if (mode !== "dev") return; rmSync(devServerStatusFilePath, { force: true }); + rmSync(devServerRestartRequestFilePath, { force: true }); +} + +function consumeDevServerRestartRequest() { + if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false; + rmSync(devServerRestartRequestFilePath, { force: true }); + return true; } async function updateDevServiceRecord(extra?: Record) { @@ -633,7 +642,8 @@ async function startServerChild() { async function maybeAutoRestartChild() { if (mode !== "dev" || restartInFlight || !child) return; - if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + const manualRestartRequested = consumeDevServerRestartRequest(); + if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return; restartInFlight = true; let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null; @@ -645,11 +655,15 @@ async function maybeAutoRestartChild() { } const devServer = health?.devServer; - if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { + if (!devServer?.enabled) { restartInFlight = false; return; } - if ((devServer.activeRunCount ?? 0) > 0) { + if (!manualRestartRequested && devServer.autoRestartEnabled !== true) { + restartInFlight = false; + return; + } + if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) { restartInFlight = false; return; } diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh index d43807b3..0c785d4a 100755 --- a/scripts/prepare-server-ui-dist.sh +++ b/scripts/prepare-server-ui-dist.sh @@ -3,13 +3,26 @@ set -euo pipefail # prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. # This keeps @paperclipai/server publish artifacts self-contained for static UI serving. +# When PAPERCLIP_RELEASE_REUSE_UI_DIST=1 and ui/dist already exists, reuse that +# output instead of rebuilding it again inside the release packaging flow. REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" UI_DIST="$REPO_ROOT/ui/dist" SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" -echo " -> Building @paperclipai/ui..." -pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +should_reuse_existing_ui_dist=false +case "${PAPERCLIP_RELEASE_REUSE_UI_DIST:-}" in + 1|true|TRUE|yes|YES) + should_reuse_existing_ui_dist=true + ;; +esac + +if [ "$should_reuse_existing_ui_dist" = true ] && [ -f "$UI_DIST/index.html" ]; then + echo " -> Reusing existing @paperclipai/ui dist output" +else + echo " -> Building @paperclipai/ui..." + pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build +fi if [ ! -f "$UI_DIST/index.html" ]; then echo "Error: UI build output missing at $UI_DIST/index.html" diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index 761065e6..cd45419f 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -34,6 +34,11 @@ "name": "@paperclipai/adapter-gemini-local", "publishFromCi": true }, + { + "dir": "packages/adapters/grok-local", + "name": "@paperclipai/adapter-grok-local", + "publishFromCi": true + }, { "dir": "packages/adapters/opencode-local", "name": "@paperclipai/adapter-opencode-local", @@ -64,6 +69,11 @@ "name": "@paperclipai/plugin-sdk", "publishFromCi": true }, + { + "dir": "packages/plugins/plugin-workspace-diff", + "name": "@paperclipai/plugin-workspace-diff", + "publishFromCi": false + }, { "dir": "server", "name": "@paperclipai/server", @@ -109,6 +119,11 @@ "name": "@paperclipai/plugin-e2b", "publishFromCi": true }, + { + "dir": "packages/plugins/sandbox-providers/modal", + "name": "@paperclipai/plugin-modal", + "publishFromCi": true + }, { "dir": "ui", "name": "@paperclipai/ui", diff --git a/scripts/release.sh b/scripts/release.sh index 9d27c9cf..35da947d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -198,6 +198,10 @@ fi set_cleanup_trap +# The release flow already prepares ui/dist before packaging. Reuse that output +# so server prepack does not rebuild the UI a second time during preview/publish. +export PAPERCLIP_RELEASE_REUSE_UI_DIST=1 + if [ "$skip_verify" = false ]; then release_info "" release_info "==> Step 1/7: Verification gate..." diff --git a/scripts/run-typecheck-build-gaps.mjs b/scripts/run-typecheck-build-gaps.mjs index 6210ab2a..e149990b 100644 --- a/scripts/run-typecheck-build-gaps.mjs +++ b/scripts/run-typecheck-build-gaps.mjs @@ -86,7 +86,7 @@ if (buildGapPackages.length === 0) { process.exit(0); } -run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "build"]); +run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"]); for (const workspacePkg of buildGapPackages) { run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index c85c48aa..a3ef4ba4 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -49,6 +49,12 @@ let invocationIndex = 0; const serializedModeName = "serialized"; const generalModeName = "general"; const allModeName = "all"; +const generalServerGroupName = "general-server"; +const generalWorkspacesAGroupName = "general-workspaces-a"; +const generalWorkspacesBGroupName = "general-workspaces-b"; +const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; +const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); +const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; function walk(dir) { const entries = readdirSync(dir); @@ -117,6 +123,7 @@ function parseCliOptions(argv) { let mode = allModeName; let shardIndex = null; let shardCount = null; + let group = null; let dryRun = false; for (let index = 0; index < argv.length; index += 1) { @@ -163,6 +170,17 @@ function parseCliOptions(argv) { continue; } + if (arg === "--group") { + group = readOptionValue(argv, index, arg); + index += 1; + continue; + } + + if (arg.startsWith("--group=")) { + group = arg.slice("--group=".length); + continue; + } + fail(`Unknown argument "${arg}".`); } @@ -178,6 +196,14 @@ function parseCliOptions(argv) { fail("--shard-index/--shard-count are only valid with --mode serialized."); } + if (group !== null && mode !== generalModeName) { + fail("--group is only valid with --mode general."); + } + + if (group !== null && !generalGroupNames.includes(group)) { + fail(`Unknown group "${group}". Expected one of: ${generalGroupNames.join(", ")}.`); + } + if (mode === serializedModeName) { const resolvedShardCount = shardCount ?? 1; const resolvedShardIndex = shardIndex ?? 0; @@ -189,6 +215,7 @@ function parseCliOptions(argv) { mode, shardIndex: resolvedShardIndex, shardCount: resolvedShardCount, + group: null, dryRun, }; } @@ -197,6 +224,7 @@ function parseCliOptions(argv) { mode, shardIndex: null, shardCount: null, + group, dryRun, }; } @@ -208,12 +236,14 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) { function runVitest(args, label) { console.log(`\n[test:run] ${label}`); invocationIndex += 1; - const testRoot = mkdtempSync(path.join(os.tmpdir(), `paperclip-vitest-${process.pid}-${invocationIndex}-`)); + const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp"; + const testRoot = mkdtempSync(path.join(tempRootParent, `pcvt-${process.pid}-${invocationIndex}-`)); + // Keep per-run paths compact so Unix socket fixtures stay under macOS path limits. const env = { ...process.env, - PAPERCLIP_HOME: path.join(testRoot, "home"), - PAPERCLIP_INSTANCE_ID: `vitest-${process.pid}-${invocationIndex}`, - TMPDIR: path.join(testRoot, "tmp"), + PAPERCLIP_HOME: path.join(testRoot, "h"), + PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`, + TMPDIR: path.join(testRoot, "t"), }; mkdirSync(env.PAPERCLIP_HOME, { recursive: true }); mkdirSync(env.TMPDIR, { recursive: true }); @@ -232,15 +262,38 @@ function runVitest(args, label) { } function runGeneralSuites(routeTests) { - const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); - for (const project of nonServerProjects) { - runVitest(["--project", project], `non-server project ${project}`); + for (const groupName of generalGroupNames) { + runGeneralGroup(routeTests, groupName); + } +} + +function runProjectGroup(projects, groupName) { + for (const project of projects) { + runVitest(["--project", project], `${groupName} project ${project}`); + } +} + +function runGeneralGroup(routeTests, groupName) { + if (groupName === generalServerGroupName) { + const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); + runVitest( + ["--project", "@paperclipai/server", ...excludeRouteArgs], + `${groupName} server suites excluding ${routeTests.length} serialized suites`, + ); + return; } - runVitest( - ["--project", "@paperclipai/server", ...excludeRouteArgs], - `server suites excluding ${routeTests.length} serialized suites`, - ); + if (groupName === generalWorkspacesAGroupName) { + runProjectGroup(generalWorkspacesAProjects, groupName); + return; + } + + if (groupName === generalWorkspacesBGroupName) { + runProjectGroup(generalWorkspacesBProjects, groupName); + return; + } + + fail(`Unknown group "${groupName}".`); } function runSerializedSuites(routeTests, shardIndex, shardCount) { @@ -283,6 +336,8 @@ if (options.dryRun) { mode: options.mode, shardIndex: options.shardIndex, shardCount: options.shardCount, + group: options.group, + availableGeneralGroups: generalGroupNames, serializedSuiteCount: routeTests.length, selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath), }, @@ -294,7 +349,11 @@ if (options.dryRun) { } if (options.mode === generalModeName || options.mode === allModeName) { - runGeneralSuites(routeTests); + if (options.group) { + runGeneralGroup(routeTests, options.group); + } else { + runGeneralSuites(routeTests); + } } if (options.mode === serializedModeName || options.mode === allModeName) { diff --git a/server/package.json b/server/package.json index 0b677ba3..9993d0da 100644 --- a/server/package.json +++ b/server/package.json @@ -50,6 +50,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", diff --git a/server/src/__tests__/access-routes-permissions-upgrade.test.ts b/server/src/__tests__/access-routes-permissions-upgrade.test.ts new file mode 100644 index 00000000..b7180581 --- /dev/null +++ b/server/src/__tests__/access-routes-permissions-upgrade.test.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + createDb, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +vi.hoisted(() => { + process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; + process.env.PAPERCLIP_INSTANCE_ID = "vitest"; + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +async function createApp(db: Db, companyId: string, userId: string) { + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const { accessRoutes } = await import("../routes/access.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId, + source: "local_implicit", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", accessRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + })); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); + }); + return app; +} + +async function createCompanyWithOwner(db: Db) { + const company = await db + .insert(companies) + .values({ + name: `Access Routes ${randomUUID()}`, + issuePrefix: `AR${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + const owner = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `owner-${randomUUID()}`, + status: "active", + membershipRole: "owner", + }) + .returning() + .then((rows) => rows[0]!); + return { company, owner }; +} + +describeEmbeddedPostgres("access routes permissions upgrade compatibility", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-access-routes-permissions-upgrade-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects owner self-lockout through the member route after the permissions upgrade", async () => { + const { company, owner } = await createCompanyWithOwner(db); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${owner.id}`) + .send({ membershipRole: "admin" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("You cannot remove yourself"); + + const unchanged = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, owner.id)) + .then((rows) => rows[0]!); + expect(unchanged.membershipRole).toBe("owner"); + }, 10_000); + + it("keeps custom grants when the role-only member route changes a member role", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + const customScope = { projectIds: ["project-1"] }; + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + + const res = await request(await createApp(db, company.id, owner.principalId)) + .patch(`/api/companies/${company.id}/members/${member.id}`) + .send({ membershipRole: "operator" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.membershipRole).toBe("operator"); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, member.principalId), + ), + ); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + permissionKey: "tasks:assign_scope", + scope: customScope, + grantedByUserId: owner.principalId, + }); + }); +}); diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index f9ac3e64..a0daac80 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + agents, companies, companyMemberships, createDb, @@ -14,6 +15,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { accessService } from "../services/access.js"; +import { grantsForHumanRole } from "../services/company-member-roles.js"; +import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,6 +59,7 @@ describeEmbeddedPostgres("access service", () => { await db.delete(issues); await db.delete(principalPermissionGrants); await db.delete(instanceUserRoles); + await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies); }); @@ -221,4 +225,285 @@ describeEmbeddedPostgres("access service", () => { access.setUserCompanyAccess(operator.principalId, [], { actorUserId: owner.principalId }), ).rejects.toThrow("Instance admins cannot be removed from company access"); }); + + it("allows owner and admin role-default grants to manage environments", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const access = accessService(db); + const roles = ["admin", "operator", "viewer"] as const; + const members = await db + .insert(companyMemberships) + .values( + roles.map((role) => ({ + companyId: company.id, + principalType: "user" as const, + principalId: `${role}-${randomUUID()}`, + status: "active" as const, + membershipRole: role, + })), + ) + .returning(); + + await access.setPrincipalGrants( + company.id, + "user", + owner.principalId, + grantsForHumanRole("owner"), + owner.principalId, + ); + for (const member of members) { + await access.setPrincipalGrants( + company.id, + "user", + member.principalId, + grantsForHumanRole(member.membershipRole as "admin" | "operator" | "viewer"), + owner.principalId, + ); + } + + const admin = members.find((member) => member.membershipRole === "admin")!; + const operator = members.find((member) => member.membershipRole === "operator")!; + const viewer = members.find((member) => member.membershipRole === "viewer")!; + + await expect(access.canUser(company.id, owner.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(access.canUser(company.id, operator.principalId, "environments:manage")).resolves.toBe(false); + await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false); + }); + + it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const scopedEnvironmentGrant = { environmentId: "env-1" }; + const humanRows = await db + .insert(companyMemberships) + .values([ + { + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }, + { + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: `viewer-${randomUUID()}`, + status: "active", + membershipRole: "viewer", + }, + { + companyId: company.id, + principalType: "user", + principalId: `legacy-${randomUUID()}`, + status: "active", + membershipRole: null, + }, + ]) + .returning(); + const admin = humanRows[0]!; + const operator = humanRows[1]!; + const viewer = humanRows[2]!; + const legacyMember = humanRows[3]!; + + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: owner.principalId, + permissionKey: "environments:manage", + scope: scopedEnvironmentGrant, + grantedByUserId: "custom-author", + }); + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.humanGrantsInserted).toBeGreaterThan(0); + expect(second.humanGrantsInserted).toBe(0); + await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false); + + const ownerEnvironmentGrants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, owner.principalId), + eq(principalPermissionGrants.permissionKey, "environments:manage"), + ), + ); + expect(ownerEnvironmentGrants).toHaveLength(1); + expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant); + expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author"); + }); + + it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => { + const { company } = await createCompanyWithOwner(db); + const agentRows = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: `Idle ${randomUUID()}`, + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Running ${randomUUID()}`, + role: "engineer", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Pending ${randomUUID()}`, + role: "engineer", + status: "pending_approval", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Terminated ${randomUUID()}`, + role: "engineer", + status: "terminated", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + ]) + .returning(); + const idleAgent = agentRows[0]!; + const runningAgent = agentRows[1]!; + const pendingAgent = agentRows[2]!; + const terminatedAgent = agentRows[3]!; + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.agentMembershipsInserted).toBe(2); + expect(second.agentMembershipsInserted).toBe(0); + const memberships = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.principalType, "agent")); + expect(memberships.map((membership) => membership.principalId).sort()).toEqual([ + idleAgent.id, + runningAgent.id, + ].sort()); + expect(memberships.every((membership) => membership.status === "active")).toBe(true); + expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true); + expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false); + expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false); + }); + + it("copies active user memberships with role-default grants for safe company imports", async () => { + const source = await createCompanyWithOwner(db); + const target = await createCompanyWithOwner(db); + const admin = await db + .insert(companyMemberships) + .values({ + companyId: source.company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + await access.copyActiveUserMemberships(source.company.id, target.company.id); + + const copiedOwnerGrants = await access.listPrincipalGrants( + target.company.id, + "user", + source.owner.principalId, + ); + const copiedAdminGrants = await access.listPrincipalGrants( + target.company.id, + "user", + admin.principalId, + ); + expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(), + ); + expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(), + ); + }); + + it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const scopedGrant = { environmentId: "env-1" }; + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: owner.principalId, + permissionKey: "environments:manage", + scope: scopedGrant, + grantedByUserId: "custom-grant-author", + }); + + await db.execute(sql.raw(` + INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" + ) + SELECT + "company_id", + 'user', + "principal_id", + 'environments:manage', + NULL, + NULL, + now(), + now() + FROM "company_memberships" + WHERE "principal_type" = 'user' + AND "status" = 'active' + AND "membership_role" IN ('owner', 'admin') + ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" + ) DO NOTHING + `)); + + const grants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, owner.principalId), + eq(principalPermissionGrants.permissionKey, "environments:manage"), + ), + ); + expect(grants).toHaveLength(1); + expect(grants[0]?.scope).toEqual(scopedGrant); + expect(grants[0]?.grantedByUserId).toBe("custom-grant-author"); + }); }); diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts index 148e52a0..6e3922e3 100644 --- a/server/src/__tests__/acpx-local-execute.test.ts +++ b/server/src/__tests__/acpx-local-execute.test.ts @@ -574,7 +574,7 @@ describe("acpx_local execute", () => { const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); const result = await execute(buildContext(root)); expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("acpx_protocol_error"); + expect(result.errorCode).toBe("acpx_session_init_failed"); expect(result.errorMeta).toMatchObject({ category: "protocol", acpCode: "ACP_SESSION_INIT_FAILED", diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 06c25c7f..b90869e6 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -174,6 +174,15 @@ describe("adapter routes", () => { expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + const grokAdapter = res.body.find((a: any) => a.type === "grok_local"); + expect(grokAdapter).toBeDefined(); + expect(grokAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + }); + // hermes_local currently supports skills + local JWT, but not the managed // instructions bundle flow because the bundled adapter does not consume // instructionsFilePath at runtime. diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index b33fd21d..198efd9c 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), ensureMembership: vi.fn(), setPrincipalPermission: vi.fn(), @@ -192,6 +193,11 @@ describe("agent routes adapter validation", () => { mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index ac5c9313..1df94d90 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -60,6 +60,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), ensureMembership: vi.fn(), @@ -293,6 +294,17 @@ function resetMockDefaults() { revokedAt: new Date("2026-04-11T00:05:00.000Z"), })); mockAccessService.canUser.mockImplementation(async () => currentAccessCanUser); + mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => { + const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit" + ? true + : currentAccessCanUser; + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockImplementation(async () => false); mockAccessService.getMembership.mockImplementation(async () => null); mockAccessService.listPrincipalGrants.mockImplementation(async () => []); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index c29fca24..f35ef610 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -21,6 +21,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -175,6 +176,11 @@ describe("agent instructions bundle routes", () => { vi.clearAllMocks(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type })); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAgentService.getById.mockResolvedValue(makeAgent()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeAgent(), diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 0eeebe52..d0549059 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -51,7 +51,16 @@ function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => ({}), - accessService: () => ({}), + accessService: () => ({ + canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), + hasPermission: vi.fn(async () => true), + }), approvalService: () => ({}), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 218d653f..28a49a69 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -51,6 +51,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), ensureMembership: vi.fn(), @@ -302,6 +303,7 @@ describe.sequential("agent permission routes", () => { mockAgentService.getChainOfCommand.mockReset(); mockAgentService.resolveByReference.mockReset(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); mockAccessService.hasPermission.mockReset(); mockAccessService.getMembership.mockReset(); mockAccessService.ensureMembership.mockReset(); @@ -342,6 +344,14 @@ describe.sequential("agent permission routes", () => { mockAgentService.update.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = Boolean(await mockAccessService.canUser()); + return { + allowed, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant" : `Missing test grant for ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", @@ -1342,6 +1352,24 @@ describe.sequential("agent permission routes", () => { expect(res.body.access.taskAssignSource).toBe("explicit_grant"); }, 15_000); + it("reports simple-mode task assignment as enabled for active company agent members", async () => { + mockAccessService.listPrincipalGrants.mockResolvedValue([]); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`)); + + expect(res.status).toBe(200); + expect(res.body.access.canAssignTasks).toBe(true); + expect(res.body.access.taskAssignSource).toBe("simple_default"); + }, 15_000); + it("keeps task assignment enabled when agent creation privilege is enabled", async () => { mockAgentService.updatePermissions.mockResolvedValue({ ...baseAgent, diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 84537a9b..1001f694 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -11,6 +11,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(), listPrincipalGrants: vi.fn(), @@ -315,6 +316,11 @@ describe.sequential("agent skill routes", () => { ); mockLogActivity.mockResolvedValue(undefined); mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockAccessService.hasPermission.mockResolvedValue(true); mockAccessService.getMembership.mockResolvedValue(null); mockAccessService.listPrincipalGrants.mockResolvedValue([]); diff --git a/server/src/__tests__/agent-test-environment-routes.test.ts b/server/src/__tests__/agent-test-environment-routes.test.ts index d951a5ee..d1a6cd1d 100644 --- a/server/src/__tests__/agent-test-environment-routes.test.ts +++ b/server/src/__tests__/agent-test-environment-routes.test.ts @@ -10,6 +10,7 @@ const mockAgentService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), getMembership: vi.fn(async () => null), listPrincipalGrants: vi.fn(async () => []), @@ -120,6 +121,11 @@ describe("agent test-environment route", () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant", + }); mockEnvironmentService.getById.mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", diff --git a/server/src/__tests__/app-hmr-port.test.ts b/server/src/__tests__/app-hmr-port.test.ts index 2f25d3ab..f6e6ac1c 100644 --- a/server/src/__tests__/app-hmr-port.test.ts +++ b/server/src/__tests__/app-hmr-port.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveViteHmrPort } from "../app.ts"; +import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts"; describe("resolveViteHmrPort", () => { it("uses serverPort + 10000 when the result stays in range", () => { @@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => { expect(resolveViteHmrPort(9_000)).toBe(19_000); }); }); + +describe("resolveViteHmrHost", () => { + it("omits wildcard bind hosts so Vite uses the browser hostname", () => { + expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined(); + expect(resolveViteHmrHost("::")).toBeUndefined(); + }); + + it("keeps concrete bind hosts", () => { + expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1"); + expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev"); + }); +}); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts new file mode 100644 index 00000000..933c460e --- /dev/null +++ b/server/src/__tests__/authorization-service.test.ts @@ -0,0 +1,547 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companyMemberships, + createDb, + instanceUserRoles, + principalPermissionGrants, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { authorizationService } from "../services/authorization.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompany(db: ReturnType, label: string) { + return db + .insert(companies) + .values({ + name: `Authorization ${label} ${randomUUID()}`, + issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createAgent( + db: ReturnType, + companyId: string, + input: { role?: string; reportsTo?: string | null; permissions?: Record } = {}, +) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + permissions: input.permissions ?? {}, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createProject(db: ReturnType, companyId: string, label: string) { + return db + .insert(projects) + .values({ + companyId, + name: `Project ${label} ${randomUUID()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function grantAgentPermission( + db: ReturnType, + companyId: string, + agentId: string, + permissionKey: "tasks:assign" | "tasks:assign_scope", + scope: Record | null = null, +) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "agent", + principalId: agentId, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId, + principalType: "agent", + principalId: agentId, + permissionKey, + scope, + grantedByUserId: null, + }); +} + +describeEmbeddedPostgres("authorization service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(instanceUserRoles); + await db.delete(agents); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows active user role grants and explains the grant source", async () => { + const company = await createCompany(db, "UserGrant"); + const userId = `user-${randomUUID()}`; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + grantedByUserId: "owner", + }); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "user", + principalId: userId, + action: "tasks:assign", + permissionKey: "tasks:assign", + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + }, + }); + expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign"); + }); + + it("allows agent grants for agent configuration decisions", async () => { + const company = await createCompany(db, "AgentGrant"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "agents:create", + grantedByUserId: null, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + }); + + expect(decision.allowed).toBe(true); + expect(decision.grant?.permissionKey).toBe("agents:create"); + }); + + it("denies cross-company agent decisions before grant evaluation", async () => { + const sourceCompany = await createCompany(db, "Source"); + const targetCompany = await createCompany(db, "Target"); + const actorAgent = await createAgent(db, sourceCompany.id); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" }, + action: "tasks:assign", + resource: { type: "company", companyId: targetCompany.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_company_boundary", + }); + expect(decision.explanation).toContain("Agent key cannot access another company"); + }); + + it("allows simple-mode task assignment between same-company agents without explicit grants", async () => { + const company = await createCompany(db, "AssignmentDefault"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + expect(decision.explanation).toContain("simple mode"); + }); + + it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => { + const company = await createCompany(db, "ProtectedAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Production deployment authority", + }, + managedBy: "permissions-extension", + }, + }, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(decision.explanation).toContain("requires approval"); + }); + + it("requires an explicit grant before assigning to a private target agent", async () => { + const company = await createCompany(db, "PrivateAssignment"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + const targetAgent = await createAgent(db, company.id, { + role: "engineer", + permissions: { + authorizationPolicy: { + agentVisibility: { + mode: "private", + hiddenFromDefaultDirectory: true, + }, + assignmentPolicy: { + mode: "company_default", + protectedAgentRequiresApproval: false, + }, + protectedAgent: { + requiresApproval: false, + }, + managedBy: "permissions-extension", + }, + }, + }); + + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentId: targetAgent.id, + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(denied.explanation).toContain("private"); + expect(allowed).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { permissionKey: "tasks:assign_scope" }, + }); + }); + + it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => { + const company = await createCompany(db, "BoardAssignmentDefault"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, source: "session" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_simple_company_member", + }); + }); + + it("denies legacy board assignment context for viewers", async () => { + const company = await createCompany(db, "BoardViewerAssignment"); + const userId = `user-${randomUUID()}`; + const targetAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "viewer", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, companyIds: [company.id], source: "session" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_missing_grant", + }); + }); + + it("denies simple-mode assignment to a target agent from another company", async () => { + const sourceCompany = await createCompany(db, "AssignmentSource"); + const targetCompany = await createCompany(db, "AssignmentTarget"); + const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" }); + const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: sourceCompany.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_company_boundary", + }); + }); + + it("preserves legacy CEO agent creator authority", async () => { + const company = await createCompany(db, "Legacy"); + const actorAgent = await createAgent(db, company.id, { role: "ceo" }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" }, + action: "agents:create", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_legacy_agent_creator", + }); + }); + + it("allows scoped assignment inside a granted project and denies other projects", async () => { + const company = await createCompany(db, "ProjectScope"); + const project = await createProject(db, company.id, "Allowed"); + const otherProject = await createProject(db, company.id, "Denied"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + projectIds: [project.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: targetAgent.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id }, + }); + + expect(allowed).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign_scope" }, + }); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + expect(denied.explanation).toContain("does not cover the requested scope"); + }); + + it("treats unknown grant scope metadata as unconstrained", async () => { + const company = await createCompany(db, "UnknownScopeMetadata"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + note: "CEO-approved", + }); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign_scope" }, + }); + }); + + it("allows scoped assignment to agents inside a managed subtree only", async () => { + const company = await createCompany(db, "SubtreeScope"); + const actorAgent = await createAgent(db, company.id); + const managerAgent = await createAgent(db, company.id); + const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id }); + const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id }); + const outsideAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + managedSubtreeAgentIds: [managerAgent.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: grandchildAgent.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: outsideAgent.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope"); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); + + it("allows scoped assignment to an explicit target-agent allowlist only", async () => { + const company = await createCompany(db, "AllowlistScope"); + const actorAgent = await createAgent(db, company.id); + const allowedTarget = await createAgent(db, company.id); + const deniedTarget = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentIds: [allowedTarget.id], + }); + + const allowed = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: allowedTarget.id }, + }); + const denied = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentId: deniedTarget.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(denied.allowed).toBe(false); + }); + + it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => { + const company = await createCompany(db, "BroadAssign"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign"); + + const decision = await authorizationService(db).decidePrincipalGrant({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + action: "tasks:assign", + permissionKey: "tasks:assign", + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign" }, + }); + }); +}); diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 488f3415..90fec295 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -454,6 +454,103 @@ describe("awsSecretsManagerProvider", () => { expect(JSON.stringify(listed)).not.toContain("team"); }); + it("discovers AWS provider vault prefill candidates from metadata without reading values", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue() { + throw new Error("not used"); + }, + async getSecretValue() { + throw new Error("GetSecretValue must not be used for provider vault discovery"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + async listSecrets(input) { + calls.push({ op: "listSecrets", input }); + return { + NextToken: "next-page", + SecretList: [ + { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai", + Name: "paperclip/prod-use1/company-1/openai", + KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod", + Tags: [ + { Key: "paperclip:managed-by", Value: "paperclip" }, + { Key: "paperclip:deployment-id", Value: "prod-use1" }, + { Key: "paperclip:company-id", Value: "company-1" }, + { Key: "paperclip:environment", Value: "production" }, + { Key: "paperclip:provider-owner", Value: "platform" }, + ], + }, + { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/stripe", + Name: "paperclip/prod-use1/company-2/stripe", + Tags: [ + { Key: "paperclip:managed-by", Value: "paperclip" }, + { Key: "paperclip:company-id", Value: "company-2" }, + ], + }, + ], + }; + }, + }, + }); + + const preview = await provider.discoverProviderConfigs?.({ + companyId: "company-1", + providerConfig: { + id: "draft", + provider: "aws_secrets_manager", + status: "ready", + config: { region: "us-east-1" }, + }, + query: "paperclip", + pageSize: 25, + }); + + expect(calls).toEqual([ + { + op: "listSecrets", + input: { + MaxResults: 25, + NextToken: undefined, + IncludePlannedDeletion: false, + Filters: [{ Key: "all", Values: ["paperclip"] }], + }, + }, + ]); + expect(preview).toMatchObject({ + provider: "aws_secrets_manager", + nextToken: "next-page", + sampledSecretCount: 1, + skippedForeignPaperclipSampleCount: 1, + candidates: [ + expect.objectContaining({ + displayName: "AWS production", + config: expect.objectContaining({ + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod", + ownerTag: "platform", + environmentTag: "production", + }), + signals: expect.objectContaining({ + paperclipManagedSampleCount: 1, + skippedForeignPaperclipSampleCount: 1, + }), + }), + ], + }); + expect(JSON.stringify(preview)).not.toContain("SecretString"); + expect(JSON.stringify(preview)).not.toContain("company-2/stripe"); + }); + it("redacts AWS provider exception text when remote listing fails", async () => { const rawProviderMessage = "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai"; diff --git a/server/src/__tests__/better-auth.test.ts b/server/src/__tests__/better-auth.test.ts index dd672d67..5d67a6ff 100644 --- a/server/src/__tests__/better-auth.test.ts +++ b/server/src/__tests__/better-auth.test.ts @@ -5,13 +5,17 @@ import { buildBetterAuthAdvancedOptions, deriveAuthCookiePrefix, deriveAuthTrustedOrigins, + shouldDisableSecureAuthCookies, } from "../auth/better-auth.js"; const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID; +const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL; afterEach(() => { if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID; + if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL; + else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL; }); describe("Better Auth cookie scoping", () => { @@ -28,8 +32,8 @@ describe("Better Auth cookie scoping", () => { expect(advanced).toEqual({ cookiePrefix: "paperclip-sat-worktree", }); - expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe( - "paperclip-sat-worktree.session_token", + expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch( + /paperclip-sat-worktree\.session_token$/, ); }); @@ -40,6 +44,110 @@ describe("Better Auth cookie scoping", () => { cookiePrefix: "paperclip-pap-worktree", useSecureCookies: false, }); + expect(getCookies({ + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }), + } as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token"); + }); + + it("disables secure cookies for authenticated private auto-origin dev servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(true); + }); + + it("keeps secure cookies for authenticated public auto-origin servers", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: undefined, + })).toBe(false); + }); + + it("uses an explicit public URL when deciding whether secure cookies are required", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + publicUrl: "https://paperclip.example.test", + })).toBe(false); + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://paperclip.local.test:3100", + publicUrl: undefined, + })).toBe(true); + }); + + it("disables secure cookies when no canonical public auth URL is configured", () => { + delete process.env.PAPERCLIP_PUBLIC_URL; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + } as Parameters[0])).toBe(true); + }); + + it("derives secure cookie behavior from the configured public auth URL", () => { + delete process.env.PAPERCLIP_PUBLIC_URL; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://paperclip-dev:46259", + } as Parameters[0])).toBe(true); + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://paperclip.example.test", + } as Parameters[0])).toBe(false); + }); + + it("uses the caller-resolved public URL for cookie security", () => { + process.env.PAPERCLIP_PUBLIC_URL = "https://ignored.example.test"; + + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://paperclip.example.test", + publicUrl: "http://paperclip-dev:46259", + } as Parameters[0])).toBe(true); + }); + + it("disables secure cookies for private authenticated auto mode without a public URL", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "auto", + authPublicBaseUrl: undefined, + })).toBe(true); + }); + + it("disables secure cookies for explicit HTTP public URLs", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "private", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "http://board.example.test:3101", + })).toBe(true); + }); + + it("keeps secure cookies for explicit HTTPS public URLs", () => { + expect(shouldDisableSecureAuthCookies({ + deploymentMode: "authenticated", + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://board.example.test", + })).toBe(false); }); it("adds hostname port variants for authenticated mode on non-default ports", () => { diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index 6dc222d7..eb3721ab 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -5,7 +5,7 @@ import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; function createApp( actorType: "board" | "agent", - boardSource: "session" | "local_implicit" | "board_key" = "session", + boardSource: "session" | "local_implicit" | "board_key" | "cloud_tenant" = "session", ) { const app = express(); app.use(express.json()); @@ -66,6 +66,12 @@ describe("boardMutationGuard", () => { expect([200, 204]).toContain(res.status); }); + it("allows trusted Cloud tenant mutations without origin", async () => { + const app = createApp("board", "cloud_tenant"); + const res = await request(app).post("/mutate").send({ ok: true }); + expect([200, 204]).toContain(res.status); + }); + it("allows board mutations from trusted origin", async () => { const app = createApp("board"); const res = await request(app) diff --git a/server/src/__tests__/body-limits.test.ts b/server/src/__tests__/body-limits.test.ts new file mode 100644 index 00000000..8164e32b --- /dev/null +++ b/server/src/__tests__/body-limits.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_JSON_BODY_LIMIT, + PORTABLE_JSON_BODY_LIMIT, + PORTABLE_JSON_BODY_LIMIT_BYTES, +} from "../http/body-limits.js"; + +describe("HTTP body limits", () => { + it("keeps the global JSON parser at the established ceiling", () => { + expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb"); + }); + + it("allows PAP-scale portable import JSON payloads", () => { + expect(PORTABLE_JSON_BODY_LIMIT).toBe("64mb"); + expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBe(64 * 1024 * 1024); + expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBeGreaterThan(10 * 1024 * 1024); + }); +}); diff --git a/server/src/__tests__/cleanup-removal-service.test.ts b/server/src/__tests__/cleanup-removal-service.test.ts index 25dc11d6..e65124cc 100644 --- a/server/src/__tests__/cleanup-removal-service.test.ts +++ b/server/src/__tests__/cleanup-removal-service.test.ts @@ -9,6 +9,7 @@ import { createDb, documents, documentRevisions, + heartbeatRunEvents, heartbeatRuns, issueComments, issueDocuments, @@ -42,6 +43,7 @@ describeEmbeddedPostgres("cleanup removal services", () => { }, 20_000); afterEach(async () => { + await db.delete(heartbeatRunEvents); await db.delete(activityLog); await db.delete(issueReadStates); await db.delete(issueComments); @@ -228,4 +230,32 @@ describeEmbeddedPostgres("cleanup removal services", () => { await expect(db.select().from(issueReadStates).where(eq(issueReadStates.companyId, companyId))).resolves.toHaveLength(0); await expect(db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).resolves.toHaveLength(0); }); + + it("removes heartbeat events by run id before deleting company-owned runs", async () => { + const { agentId, companyId, runId } = await seedFixture(); + const otherCompanyId = randomUUID(); + + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other Company", + issuePrefix: `O${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(heartbeatRunEvents).values({ + companyId: otherCompanyId, + runId, + agentId, + seq: 1, + eventType: "output", + message: "event with mismatched company scope", + }); + + const removed = await companyService(db).remove(companyId); + + expect(removed?.id).toBe(companyId); + await expect(db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).resolves.toHaveLength(0); + await expect(db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).resolves.toHaveLength(0); + await expect(db.select().from(companies).where(eq(companies.id, otherCompanyId))).resolves.toHaveLength(1); + }); }); diff --git a/server/src/__tests__/cloud-upstreams.test.ts b/server/src/__tests__/cloud-upstreams.test.ts new file mode 100644 index 00000000..fce78dfd --- /dev/null +++ b/server/src/__tests__/cloud-upstreams.test.ts @@ -0,0 +1,334 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, cloudUpstreamConnections, cloudUpstreamRuns, companySkills, createDb } from "@paperclipai/db"; + +import { HttpError } from "../errors.js"; +import { + cloudUpstreamRemoteFailureReport, + cloudUpstreamService, + reconcileCloudUpstreamRunsOnStartup, + sealCloudUpstreamCredential, + unsealCloudUpstreamCredential, +} from "../services/cloud-upstreams.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres cloud upstream tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("cloud upstream remote failures", () => { + it("preserves the cloud response body and message on run reports", () => { + const body = { + error: "bad_request", + message: "entities[42].body must be an object", + errors: [{ path: "entities[42].body" }], + }; + + expect(cloudUpstreamRemoteFailureReport(new HttpError(400, "bad_request", body))).toEqual({ + error: "bad_request", + errorMessage: "entities[42].body must be an object", + details: body, + }); + }); + + it("falls back to the thrown error message for non-remote failures", () => { + expect(cloudUpstreamRemoteFailureReport(new Error("network failed"))).toEqual({ + error: "network failed", + }); + }); +}); + +describe("cloud upstream credential storage", () => { + const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + + afterEach(() => { + if (previousMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey; + } + }); + + it("stores new credentials as encrypted envelopes and preserves legacy plaintext reads", async () => { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012"; + const sealed = await sealCloudUpstreamCredential("cloud-access-token"); + + expect(sealed).toMatch(/^paperclip-cloud-credential:/); + expect(sealed).not.toContain("cloud-access-token"); + await expect(unsealCloudUpstreamCredential(sealed)).resolves.toBe("cloud-access-token"); + await expect(unsealCloudUpstreamCredential("legacy-plaintext-token")).resolves.toBe("legacy-plaintext-token"); + }); +}); + +describeEmbeddedPostgres("cloud upstream persistence", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + + beforeAll(async () => { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "12345678901234567890123456789012"; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-upstreams-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + vi.restoreAllMocks(); + await db.delete(cloudUpstreamRuns); + await db.delete(cloudUpstreamConnections); + await db.delete(companySkills); + await db.delete(companies); + }); + + afterAll(async () => { + if (previousMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey; + } + await tempDb?.cleanup(); + }); + + it("encrypts stored upstream credentials while keeping connection flows usable", async () => { + const companyId = randomUUID(); + await seedCompany(companyId); + const tokenUrl = "https://cloud.example.test/oauth/token"; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + if (url.startsWith("https://cloud.example.test/.well-known/paperclip-upstream")) { + return jsonResponse({ + product: "Paperclip Cloud", + stack: { + id: "stack-1", + companyId: "cloud-company-1", + origin: "https://cloud.example.test", + primaryHost: "cloud.example.test", + }, + transfer: { + supportedSchemaMajor: 1, + maxChunkBytes: 8192, + }, + auth: { + scopes: ["upstream_import:write"], + pkce: { + authorizeUrl: "https://cloud.example.test/oauth/authorize", + tokenUrl, + }, + }, + }); + } + if (url === tokenUrl && init?.method === "POST") { + const payload = JSON.parse(String(init.body)); + expect(payload.codeVerifier).toEqual(expect.any(String)); + expect(payload.codeVerifier).not.toContain("paperclip-cloud-credential:"); + return jsonResponse({ + accessToken: "cloud-access-token", + token: { + id: "token-1", + expiresAt: "2026-05-22T13:00:00.000Z", + globalUserId: "user-1", + }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + const service = cloudUpstreamService(db, { instanceId: "test" }); + const started = await service.startConnect({ + companyId, + remoteUrl: "https://cloud.example.test", + redirectUri: "http://localhost:3100/callback", + }); + await service.finishConnect({ + pendingConnectionId: started.pendingConnectionId, + code: "auth-code", + state: new URL(started.authorizationUrl).searchParams.get("state") ?? "", + }); + + const [row] = await db.select().from(cloudUpstreamConnections); + expect(row.privateKeyPem).toMatch(/^paperclip-cloud-credential:/); + expect(row.privateKeyPem).not.toContain("BEGIN PRIVATE KEY"); + expect(row.accessToken).toMatch(/^paperclip-cloud-credential:/); + expect(row.accessToken).not.toContain("cloud-access-token"); + }); + + it("marks orphaned running runs failed during startup reconciliation", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + const runningRunId = randomUUID(); + const succeededRunId = randomUUID(); + const reconciledAt = new Date("2026-05-22T13:00:00.000Z"); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values({ + id: connectionId, + companyId, + remoteUrl: "https://cloud.example.test", + sourceInstanceId: "source-1", + sourceInstanceFingerprint: "sha256:test", + sourcePublicKey: "public-key", + privateKeyPem: "legacy-private-key", + tokenStatus: "connected", + scopes: ["upstream_import:write"], + authorizedGlobalUserId: "user-1", + accessToken: "legacy-token", + tokenId: "token-1", + targetStackId: "stack-1", + targetCompanyId: "cloud-company-1", + targetOrigin: "https://cloud.example.test", + targetPrimaryHost: "cloud.example.test", + targetProduct: "Paperclip Cloud", + targetSchemaMajor: 1, + targetMaxChunkBytes: 8192, + }); + await db.insert(cloudUpstreamRuns).values([ + cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }), + cloudRunRow({ id: succeededRunId, connectionId, companyId, status: "succeeded", completedAt: reconciledAt }), + ]); + + await expect(reconcileCloudUpstreamRunsOnStartup(db, reconciledAt)).resolves.toEqual({ reconciled: 1 }); + + const rows = await db.select().from(cloudUpstreamRuns); + const running = rows.find((row) => row.id === runningRunId); + const succeeded = rows.find((row) => row.id === succeededRunId); + expect(running?.status).toBe("failed"); + expect(running?.completedAt?.toISOString()).toBe(reconciledAt.toISOString()); + expect(running?.events.at(-1)?.message).toContain("server startup"); + expect(running?.report).toMatchObject({ + error: "orphaned_running_run", + reconciledAt: reconciledAt.toISOString(), + }); + expect(succeeded?.status).toBe("succeeded"); + }); + + it("rejects a new run when the connection already has a running run", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + const runningRunId = randomUUID(); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId })); + await db.insert(cloudUpstreamRuns).values( + cloudRunRow({ id: runningRunId, connectionId, companyId, status: "running" }), + ); + + await expect(cloudUpstreamService(db).createRun({ connectionId, companyId })).rejects.toMatchObject({ + status: 409, + details: { runId: runningRunId }, + }); + }); + + it("preserves a cancelled run when an in-flight createRun tries to finish", async () => { + const companyId = randomUUID(); + const connectionId = randomUUID(); + await seedCompany(companyId); + await db.insert(cloudUpstreamConnections).values(cloudConnectionRow({ id: connectionId, companyId })); + + const service = cloudUpstreamService(db); + const remoteCalls: string[] = []; + globalThis.fetch = vi.fn(async (input) => { + const path = new URL(String(input)).pathname; + remoteCalls.push(path); + if (path.endsWith("/upstream-imports/runs")) { + return jsonResponse({ run: { id: "remote-run-1" } }); + } + if (path.endsWith("/chunks")) { + const run = await db.select().from(cloudUpstreamRuns).then((rows) => rows[0]); + expect(run?.status).toBe("running"); + await service.cancelRun(connectionId, run.id, companyId); + return jsonResponse({ ok: true }); + } + if (path.endsWith("/cancel")) { + return jsonResponse({ ok: true }); + } + if (path.endsWith("/apply")) { + return jsonResponse({ ok: true }); + } + if (path.endsWith("/events")) { + return jsonResponse({ events: [] }); + } + return jsonResponse({ error: "not_found" }, 404); + }) as typeof fetch; + + const result = await service.createRun({ connectionId, companyId }); + + expect(result.status).toBe("cancelled"); + expect(remoteCalls.some((path) => path.endsWith("/apply"))).toBe(false); + const rows = await db.select().from(cloudUpstreamRuns); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("cancelled"); + }); + + async function seedCompany(companyId: string) { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + } +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function cloudConnectionRow(input: { id: string; companyId: string }) { + const { privateKey } = generateKeyPairSync("ed25519"); + return { + id: input.id, + companyId: input.companyId, + remoteUrl: "https://cloud.example.test", + sourceInstanceId: "source-1", + sourceInstanceFingerprint: "sha256:test", + sourcePublicKey: "public-key", + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + tokenStatus: "connected", + scopes: ["upstream_import:write"], + authorizedGlobalUserId: "user-1", + accessToken: "legacy-token", + tokenId: "token-1", + targetStackId: "stack-1", + targetCompanyId: "cloud-company-1", + targetOrigin: "https://cloud.example.test", + targetPrimaryHost: "cloud.example.test", + targetProduct: "Paperclip Cloud", + targetSchemaMajor: 1, + targetMaxChunkBytes: 8192, + }; +} + +function cloudRunRow(input: { + id: string; + connectionId: string; + companyId: string; + status: string; + completedAt?: Date; +}) { + return { + id: input.id, + connectionId: input.connectionId, + companyId: input.companyId, + status: input.status, + activeStep: "push", + progressPercent: input.status === "running" ? 45 : 100, + dryRun: false, + summary: [], + warnings: [], + conflicts: [], + events: [], + report: {}, + idempotencyKey: `key-${input.id}`, + manifestHash: `sha256:${input.id.replace(/-/g, "")}`, + targetUrl: "https://cloud.example.test", + completedAt: input.completedAt, + }; +} diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts new file mode 100644 index 00000000..8977b041 --- /dev/null +++ b/server/src/__tests__/companies-service.test.ts @@ -0,0 +1,50 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companyService } from "../services/companies.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companyService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("retries generated issue prefixes when Drizzle wraps the unique constraint error", async () => { + await db.insert(companies).values({ + name: "Aron Existing", + issuePrefix: "ARO", + }); + + const created = await companyService(db).create({ + name: "Aron & Sharon", + }); + + expect(created.issuePrefix).toBe("AROA"); + + const rows = await db.select({ issuePrefix: companies.issuePrefix }).from(companies); + expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]); + }); +}); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 26f6df16..42590525 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -139,6 +139,58 @@ function createExportResult() { }; } +const importRequest = { + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "existing_company", companyId }, + collisionStrategy: "rename", +}; + +const cloudHeaders = { + "x-paperclip-cloud-stack-id": "stack-alpha", + "x-paperclip-cloud-paperclip-company-id": companyId, +}; + +function cloudTenantActor() { + return { + type: "board", + userId: "cloud-user-1", + userName: "Cloud User", + userEmail: "cloud-user@example.com", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + isInstanceAdmin: true, + source: "cloud_tenant", + }; +} + +function createImportResult(action = "updated") { + return { + company: { id: companyId, action }, + agents: [{ id: "agent-1" }], + warnings: [], + }; +} + +async function waitForImportJobStatus(app: express.Express, statusUrl: string, status: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const res = await request(app).get(statusUrl).set(cloudHeaders); + if (res.body.job?.status === status) { + return res; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for import job to reach ${status}`); +} + +async function waitForCondition(condition: () => boolean, label: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${label}`); +} + describe.sequential("company portability routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -426,4 +478,116 @@ describe.sequential("company portability routes", () => { expect(res.body.error).toContain("Instance admin"); expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); }); + + it.sequential("accepts trusted Cloud async import jobs and reports success by job id", async () => { + let resolveImport: (value: ReturnType) => void = () => undefined; + const pendingImport = new Promise>((resolve) => { + resolveImport = resolve; + }); + mockCompanyPortabilityService.importBundle.mockReturnValueOnce(pendingImport); + const app = await createApp(cloudTenantActor()); + + const accepted = await request(app) + .post("/api/companies/import") + .set("x-paperclip-cloud-async-import", "1") + .set(cloudHeaders) + .send(importRequest); + + expect(accepted.status).toBe(202); + expect(accepted.body.job.status).toBe("running"); + expect(accepted.body.statusUrl).toMatch(/^\/api\/companies\/import\/jobs\/tenant-import-/); + expect(accepted.body.retryAfterMs).toBe(1000); + await waitForCondition(() => mockCompanyPortabilityService.importBundle.mock.calls.length === 1, "import job start"); + expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1"); + expect(mockLogActivity).not.toHaveBeenCalled(); + + resolveImport(createImportResult("updated")); + const succeeded = await waitForImportJobStatus(app, accepted.body.statusUrl, "succeeded"); + + expect(succeeded.status).toBe(200); + expect(succeeded.body.job.status).toBe("succeeded"); + expect(succeeded.body.job.result.companyId).toBe(companyId); + expect(succeeded.body.retryAfterMs).toBeUndefined(); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.imported", + companyId, + details: expect.objectContaining({ + agentCount: 1, + warningCount: 0, + companyAction: "updated", + }), + })); + + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.parse(succeeded.body.job.completedAt) + (5 * 60 * 1000) + 1); + try { + const expired = await request(app).get(accepted.body.statusUrl).set(cloudHeaders); + expect(expired.status).toBe(404); + expect(expired.body.error).toBe("Import job not found"); + } finally { + nowSpy.mockRestore(); + } + }); + + it.sequential("reports trusted Cloud async import job failures with the tenant error message", async () => { + mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("tenant import exploded")); + const app = await createApp(cloudTenantActor()); + + const accepted = await request(app) + .post("/api/companies/import") + .set("x-paperclip-cloud-async-import", "1") + .set(cloudHeaders) + .send(importRequest); + + expect(accepted.status).toBe(202); + const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed"); + + expect(failed.status).toBe(200); + expect(failed.body.job.status).toBe("failed"); + expect(failed.body.job.error.message).toBe("tenant import exploded"); + expect(failed.body.retryAfterMs).toBeUndefined(); + expect(failed.body.message).toBe("tenant import exploded"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it.sequential("accepts trusted Cloud async import jobs before validating the full import payload", async () => { + const app = await createApp(cloudTenantActor()); + + const accepted = await request(app) + .post("/api/companies/import") + .set("x-paperclip-cloud-async-import", "1") + .set(cloudHeaders) + .send({ target: { mode: "existing_company", companyId } }); + + expect(accepted.status).toBe(202); + expect(accepted.body.job.status).toBe("running"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + + const failed = await waitForImportJobStatus(app, accepted.body.statusUrl, "failed"); + + expect(failed.status).toBe(200); + expect(failed.body.job.status).toBe("failed"); + expect(failed.body.job.error.message).toEqual(expect.any(String)); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it.sequential("keeps global import apply synchronous when Cloud async opt-in is absent", async () => { + mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created")); + const app = await createApp(cloudTenantActor()); + + const res = await request(app) + .post("/api/companies/import") + .set(cloudHeaders) + .send(importRequest); + + expect(res.status).toBe(200); + expect(res.body.company.id).toBe(companyId); + expect(res.body.company.action).toBe("created"); + expect(res.body.job).toBeUndefined(); + expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1"); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.imported", + companyId, + })); + }); }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index bcdadea5..46f30133 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -21,6 +21,7 @@ const agentSvc = { const accessSvc = { ensureMembership: vi.fn(), + ensureRoleDefaultGrants: vi.fn(), listActiveUserMemberships: vi.fn(), copyActiveUserMemberships: vi.fn(), setPrincipalPermission: vi.fn(), diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index 461f2aab..dc326edd 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -63,6 +63,42 @@ describe("dev-runner worktree env bootstrap", () => { expect(env.PAPERCLIP_OPTIONAL).toBe(""); }); + it("repairs stale migrated config paths before loading worktree env", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-migrated-env-"); + const localConfigPath = path.join(root, ".paperclip", "config.json"); + const worktreesDir = path.join(root, ".paperclip-worktrees"); + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + fs.writeFileSync(localConfigPath, "{}\n", "utf8"); + fs.writeFileSync( + resolveWorktreeEnvFilePath(root), + [ + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=feature-worktree", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/feature/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "", + ].join("\n"), + "utf8", + ); + + const env: NodeJS.ProcessEnv = { + PAPERCLIP_WORKTREES_DIR: worktreesDir, + }; + const result = bootstrapDevRunnerWorktreeEnv(root, env); + + expect(result).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: false, + }); + expect(env.PAPERCLIP_HOME).toBe(worktreesDir); + expect(env.PAPERCLIP_CONFIG).toBe(localConfigPath); + expect(env.PAPERCLIP_CONTEXT).toBe(path.join(worktreesDir, "context.json")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree"); + }); + 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"); diff --git a/server/src/__tests__/dev-server-status.test.ts b/server/src/__tests__/dev-server-status.test.ts index 52eef387..052eacad 100644 --- a/server/src/__tests__/dev-server-status.test.ts +++ b/server/src/__tests__/dev-server-status.test.ts @@ -1,8 +1,13 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { + getDevServerRestartRequestFilePath, + readPersistedDevServerStatus, + toDevServerHealthStatus, + writeDevServerRestartRequest, +} from "../dev-server-status.js"; const tempDirs = []; @@ -73,4 +78,26 @@ describe("dev server status helpers", () => { expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull(); }); + + it("writes restart requests next to the persisted status file", () => { + const filePath = createTempStatusFile({ + dirty: true, + changedPathsSample: ["server/src/app.ts"], + pendingMigrations: [], + }); + + const env = { PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath }; + expect(writeDevServerRestartRequest({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }, env)).toBe(true); + + const requestPath = getDevServerRestartRequestFilePath(env); + expect(requestPath).toBe(path.join(path.dirname(filePath), "dev-server-restart-request.json")); + expect(requestPath && existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath!, "utf8"))).toEqual({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }); + }); }); diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index 12658845..44a6d3b1 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -112,4 +112,85 @@ describeEmbeddedPostgres("documentService system issue documents", () => { body: "# Handoff", })); }); + + it("locks and unlocks issue documents", async () => { + const { issueId } = await createIssueWithDocuments(); + + const locked = await svc.lockIssueDocument({ + issueId, + key: "plan", + lockedByUserId: "board-user", + }); + + expect(locked.changed).toBe(true); + expect(locked.document.lockedAt).toBeInstanceOf(Date); + expect(locked.document.lockedByUserId).toBe("board-user"); + + await expect(svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Updated plan", + baseRevisionId: locked.document.latestRevisionId, + createdByUserId: "board-user", + })).rejects.toMatchObject({ + status: 409, + message: "Document is locked", + }); + + const unlocked = await svc.unlockIssueDocument(issueId, "plan"); + expect(unlocked.changed).toBe(true); + expect(unlocked.document.lockedAt).toBeNull(); + + const updated = await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Updated plan", + baseRevisionId: unlocked.document.latestRevisionId, + createdByUserId: "board-user", + }); + + expect(updated.created).toBe(false); + expect(updated.document.body).toBe("# Updated plan"); + }); + + it("creates a new document instead of updating a locked document when requested", async () => { + const { issueId } = await createIssueWithDocuments(); + const locked = await svc.lockIssueDocument({ + issueId, + key: "plan", + lockedByUserId: "board-user", + }); + + const fallback = await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Agent replacement plan", + baseRevisionId: locked.document.latestRevisionId, + lockedDocumentStrategy: "create_new_document", + }); + + expect(fallback.created).toBe(true); + expect(fallback.document.key).toBe("plan-2"); + expect(fallback.document.body).toBe("# Agent replacement plan"); + expect("redirectedFromLockedDocument" in fallback ? fallback.redirectedFromLockedDocument : null) + .toEqual({ id: locked.document.id, key: "plan" }); + + const originalPlan = await svc.getIssueDocumentByKey(issueId, "plan"); + expect(originalPlan).toEqual(expect.objectContaining({ + body: "# Plan", + lockedAt: expect.any(Date), + })); + + const newPlan = await svc.getIssueDocumentByKey(issueId, "plan-2"); + expect(newPlan).toEqual(expect.objectContaining({ + body: "# Agent replacement plan", + lockedAt: null, + })); + }); }); diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 524d58ba..73606e05 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -84,6 +84,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), documentService: () => ({}), routineService: () => ({}), workProductService: () => ({}), diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts index d01a8c3c..483403e0 100644 --- a/server/src/__tests__/error-handler.test.ts +++ b/server/src/__tests__/error-handler.test.ts @@ -37,6 +37,31 @@ describe("errorHandler", () => { expect(res.__errorContext?.error?.message).toBe("boom"); }); + it("exposes raw 500 messages for trusted Cloud tenant imports", () => { + const req = { + ...makeReq(), + method: "POST", + originalUrl: "/api/companies/import", + actor: { + type: "board", + userId: "cloud-user", + source: "cloud_tenant", + }, + } as unknown as Request; + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new Error("portable file references missing upload id"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: "Internal server error", + message: "portable file references missing upload id", + }); + expect(res.err).toBe(err); + }); + it("attaches HttpError instances for 500 responses", () => { const req = makeReq(); const res = makeRes() as any; diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index bbac9d61..bb38461d 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -25,15 +25,15 @@ vi.mock("../services/index.js", () => ({ workspaceOperationService: () => mockWorkspaceOperationService, })); -function createApp() { +function createApp(companyIds = ["company-1"]) { 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", + companyIds, + source: "session", isInstanceAdmin: false, }; next(); @@ -55,6 +55,7 @@ describe.sequential("execution workspace routes", () => { projectWorkspaceId: null, }, ]); + mockExecutionWorkspaceService.getById.mockResolvedValue(null); }); it("uses summary mode for lightweight workspace lookups", async () => { @@ -79,4 +80,5 @@ describe.sequential("execution workspace routes", () => { }); expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); }); + }); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index f4c87e5f..fc8692fd 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -343,6 +343,83 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull(); }); + it("limits reusable summaries to open non-shared execution workspaces", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const openWorkspaceId = randomUUID(); + const sharedWorkspaceId = randomUUID(); + const closedWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Reusable workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(executionWorkspaces).values([ + { + id: openWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Open isolated workspace", + status: "idle", + providerType: "git_worktree", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }, + { + id: sharedWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared session", + status: "active", + providerType: "local_fs", + cwd: "/tmp/project-primary", + }, + { + id: closedWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Closed isolated workspace", + status: "active", + providerType: "git_worktree", + cwd: "/tmp/closed-workspace", + closedAt: new Date("2026-05-23T20:00:00.000Z"), + }, + ]); + + const summaries = await svc.listSummaries(companyId, { + projectId, + reuseEligible: true, + }); + + expect(summaries).toEqual([ + expect.objectContaining({ + id: openWorkspaceId, + name: "Open isolated workspace", + mode: "isolated_workspace", + status: "idle", + cwd: "/tmp/open-workspace", + branchName: "paperclip/open", + }), + ]); + }); + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { const repoRoot = await createTempRepo(); tempDirs.add(repoRoot); diff --git a/server/src/__tests__/feedback-flush-controller.test.ts b/server/src/__tests__/feedback-flush-controller.test.ts new file mode 100644 index 00000000..b8f12908 --- /dev/null +++ b/server/src/__tests__/feedback-flush-controller.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isDatabaseConnectionUnavailableError } from "../app.js"; + +describe("feedback export flush error classification", () => { + it("recognizes wrapped database connection-refused errors", () => { + const error = new Error("Failed query: select ...: connect ECONNREFUSED 127.0.0.1:54329"); + (error as { cause?: unknown }).cause = Object.assign( + new Error("connect ECONNREFUSED 127.0.0.1:54329"), + { code: "ECONNREFUSED" }, + ); + + expect(isDatabaseConnectionUnavailableError(error)).toBe(true); + }); + + it("does not classify ordinary feedback upload failures as database outages", () => { + expect(isDatabaseConnectionUnavailableError(new Error("upstream returned 500"))).toBe(false); + }); + + it("does not trust unrelated error messages that mention ECONNREFUSED", () => { + expect(isDatabaseConnectionUnavailableError( + new Error("feedback upload payload mentioned ECONNREFUSED in user content"), + )).toBe(false); + }); +}); diff --git a/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs new file mode 100644 index 00000000..9e1f06df --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-invocation-scope.cjs @@ -0,0 +1,100 @@ +const readline = require("node:readline"); + +let nextRequestId = 1; +const pendingNested = new Map(); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendNestedHostRequest(originalRequest, invocationId) { + const nestedId = `nested-${nextRequestId++}`; + const params = originalRequest.params?.params ?? {}; + const mode = params.mode; + const requestedCompanyId = params.requestedCompanyId; + const nestedRequest = { + jsonrpc: "2.0", + id: nestedId, + method: "companies.get", + params: { + companyId: requestedCompanyId, + }, + }; + + if (mode === "echo") { + nestedRequest.paperclipInvocationId = invocationId; + } else if (mode === "unknown") { + nestedRequest.paperclipInvocationId = "unknown-invocation"; + } + + pendingNested.set(nestedId, originalRequest.id); + send(nestedRequest); +} + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + if (!line.trim()) return; + const message = JSON.parse(line); + + if (message.id && pendingNested.has(message.id)) { + const originalId = pendingNested.get(message.id); + pendingNested.delete(message.id); + if (message.error) { + send({ + jsonrpc: "2.0", + id: originalId, + error: message.error, + }); + return; + } + + send({ + jsonrpc: "2.0", + id: originalId, + result: message.result, + }); + return; + } + + const method = message && typeof message.method === "string" ? message.method : null; + + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + ok: true, + supportedMethods: ["getData", "performAction"], + }, + }); + return; + } + + if (method === "getData" || method === "performAction") { + sendNestedHostRequest(message, message.paperclipInvocation?.id); + return; + } + + if (method === "shutdown") { + send({ + jsonrpc: "2.0", + id: message.id, + result: {}, + }); + setImmediate(() => process.exit(0)); + return; + } + + send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); +}); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index 7c768a35..536ef66c 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import express from "express"; @@ -126,3 +126,80 @@ describe("GET /health dev-server supervisor access", () => { } }); }); + +describe("POST /health/dev-server/restart", () => { + it("records a manual restart request for the dev runner", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + + try { + const app = express(); + app.use("/health", healthRoutes(undefined)); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ status: "restart_requested" }); + + const requestPath = path.join( + path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE), + "dev-server-restart-request.json", + ); + expect(existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({ + reason: "manual_restart_now", + }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); + + it("rejects unauthenticated manual restarts in authenticated mode", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + }); + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "board_access_required" }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); +}); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts new file mode 100644 index 00000000..c09fb028 --- /dev/null +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -0,0 +1,279 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { eq, ne } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agentTaskSessions, + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + issues, + projects, + projectWorkspaces, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; + +const execFileAsync = promisify(execFile); + +const adapterExecute = vi.hoisted(() => vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + sessionParams: { sessionId: "fresh-session" }, + sessionDisplayId: "fresh-session", + summary: "Accepted plan workspace refresh test run.", + provider: "test", + model: "test-model", +}))); + +vi.mock("../adapters/index.js", () => ({ + getServerAdapter: () => ({ + type: "codex_local", + execute: adapterExecute, + supportsLocalAgentJwt: false, + }), + listAdapterModelProfiles: async () => [], + runningProcesses: new Map(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres accepted-plan workspace refresh tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function createGitRepo() { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-")); + await execFileAsync("git", ["init"], { cwd: repoRoot }); + await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot }); + await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot }); + await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n"); + await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot }); + await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot }); + return repoRoot; +} + +describeEmbeddedPostgres("accepted plan workspace refresh", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const tempRoots: string[] = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-workspace-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + adapterExecute.mockClear(); + let idlePolls = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + const runs = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns); + const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running"); + if (!hasActiveRun) { + idlePolls += 1; + if (idlePolls >= 5) break; + } else { + idlePolls = 0; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined); + } + }); + + afterAll(async () => { + await db.$client.end(); + await tempDb?.cleanup(); + }); + + it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const sharedExecutionWorkspaceId = randomUUID(); + const issueId = randomUUID(); + const agentId = randomUUID(); + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Accepted Plan Workspace Refresh", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + cwd: repoRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: sharedExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared planning workspace", + status: "active", + cwd: repoRoot, + providerType: "local_fs", + providerRef: repoRoot, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId, + title: "Implement accepted plan", + status: "in_progress", + workMode: "planning", + priority: "medium", + assigneeAgentId: agentId, + identifier: "PAP-9122", + executionWorkspaceId: sharedExecutionWorkspaceId, + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agentTaskSessions).values({ + companyId, + agentId, + adapterType: "codex_local", + taskKey: issueId, + sessionParamsJson: { + sessionId: "stale-shared-session", + cwd: repoRoot, + workspaceId: projectWorkspaceId, + }, + sessionDisplayId: "stale-shared-session", + }); + adapterExecute.mockImplementationOnce(async () => { + await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { + exitCode: 0, + signal: null, + timedOut: false, + sessionParams: { sessionId: "fresh-session" }, + sessionDisplayId: "fresh-session", + summary: "Accepted plan workspace refresh test run.", + provider: "test", + model: "test-model", + }; + }); + + const heartbeat = heartbeatService(db); + const run = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }, + }); + + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await heartbeat.getRun(run!.id); + expect(latest?.status).toBe("succeeded"); + }, { timeout: 10_000 }); + + expect(adapterExecute).toHaveBeenCalledTimes(1); + const adapterInput = adapterExecute.mock.calls[0]?.[0] as { + runtime: { sessionId: string | null; sessionParams: Record | null }; + context: Record; + }; + expect(adapterInput.runtime.sessionId).toBeNull(); + expect(adapterInput.runtime.sessionParams).toBeNull(); + expect(adapterInput.context.paperclipWorkspace).toEqual(expect.objectContaining({ + mode: "isolated_workspace", + strategy: "git_worktree", + })); + expect((adapterInput.context.paperclipWorkspace as { cwd: string }).cwd).not.toBe(repoRoot); + + const refreshedIssue = await db + .select({ + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0]); + expect(refreshedIssue?.executionWorkspaceId).toBeTruthy(); + expect(refreshedIssue?.executionWorkspaceId).not.toBe(sharedExecutionWorkspaceId); + expect(refreshedIssue?.executionWorkspaceSettings).toMatchObject({ + mode: "isolated_workspace", + }); + + const isolatedRows = await db + .select() + .from(executionWorkspaces) + .where(ne(executionWorkspaces.id, sharedExecutionWorkspaceId)); + expect(isolatedRows).toHaveLength(1); + expect(isolatedRows[0]).toMatchObject({ + mode: "isolated_workspace", + strategyType: "git_worktree", + sourceIssueId: issueId, + }); + expect(isolatedRows[0]?.cwd).not.toBe(repoRoot); + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts index 166f8fc4..a5b3abb5 100644 --- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts +++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts @@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + activityLog, agents, companies, createDb, + heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, + issueComments, + issueRecoveryActions, issueRelations, issues, } from "@paperclipai/db"; @@ -94,7 +98,15 @@ describeEmbeddedPostgres("active-run output watchdog", () => { await tempDb?.cleanup(); }); - async function seedRunningRun(opts: { now: Date; ageMs: number; withOutput?: boolean; logChunk?: string }) { + async function seedRunningRun(opts: { + now: Date; + ageMs: number; + withOutput?: boolean; + logChunk?: string; + sourceStatus?: "in_progress" | "done" | "cancelled"; + sourceOriginKind?: string; + sameRunTerminalEvidence?: "activity" | "comment"; + }) { const companyId = randomUUID(); const managerId = randomUUID(); const coderId = randomUUID(); @@ -103,6 +115,8 @@ describeEmbeddedPostgres("active-run output watchdog", () => { const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const startedAt = new Date(opts.now.getTime() - opts.ageMs); const lastOutputAt = opts.withOutput ? new Date(opts.now.getTime() - 5 * 60 * 1000) : null; + const sourceStatus = opts.sourceStatus ?? "in_progress"; + const terminalEvidenceAt = new Date(startedAt.getTime() + 10 * 60 * 1000); await db.insert(companies).values({ id: companyId, @@ -139,11 +153,14 @@ describeEmbeddedPostgres("active-run output watchdog", () => { id: issueId, companyId, title: "Long running implementation", - status: "in_progress", + status: sourceStatus, priority: "medium", assigneeAgentId: coderId, issueNumber: 1, identifier: `${issuePrefix}-1`, + originKind: opts.sourceOriginKind ?? "manual", + completedAt: sourceStatus === "done" ? terminalEvidenceAt : null, + cancelledAt: sourceStatus === "cancelled" ? terminalEvidenceAt : null, updatedAt: startedAt, createdAt: startedAt, }); @@ -181,6 +198,35 @@ describeEmbeddedPostgres("active-run output watchdog", () => { .where(eq(heartbeatRuns.id, runId)); } await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + if (opts.sameRunTerminalEvidence === "activity") { + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: coderId, + agentId: coderId, + runId, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + details: { + identifier: `${issuePrefix}-1`, + status: sourceStatus, + _previous: { status: "in_progress" }, + }, + createdAt: terminalEvidenceAt, + }); + } else if (opts.sameRunTerminalEvidence === "comment") { + await db.insert(issueComments).values({ + companyId, + issueId, + authorAgentId: coderId, + authorType: "agent", + createdByRunId: runId, + body: "Completed and verified.", + createdAt: terminalEvidenceAt, + updatedAt: terminalEvidenceAt, + }); + } return { companyId, managerId, coderId, issueId, runId, issuePrefix }; } @@ -271,6 +317,211 @@ describeEmbeddedPostgres("active-run output watchdog", () => { expect(source?.status).toBe("blocked"); }); + it("folds terminal source issues with same-run durable evidence instead of creating watchdog work", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, coderId, issueId, runId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + sameRunTerminalEvidence: "activity", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 0, folded: 1, skipped: 0 }); + const evaluations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluations).toHaveLength(0); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("succeeded"); + expect(run?.errorCode).toBeNull(); + expect(run?.finishedAt?.toISOString()).toBe(now.toISOString()); + expect(run?.resultJson).toMatchObject({ + sourceResolvedWatchdogFold: { + sourceIssueId: issueId, + sourceIssueStatus: "done", + sameRunEvidenceKind: "activity", + evaluationIssueId: null, + evaluationIssueIdentifier: null, + cleanup: { outcome: "no_process_metadata" }, + }, + }); + + const [source] = await db.select().from(issues).where(eq(issues.id, issueId)); + expect(source?.executionRunId).toBeNull(); + const [agent] = await db.select().from(agents).where(eq(agents.id, coderId)); + expect(agent?.status).toBe("idle"); + const [decision] = await db + .select() + .from(heartbeatRunWatchdogDecisions) + .where(eq(heartbeatRunWatchdogDecisions.runId, runId)); + expect(decision?.decision).toBe("dismissed_false_positive"); + const [event] = await db + .select() + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + expect(event?.message).toContain("Source-resolved watchdog fold"); + }); + + it("still escalates terminal source issues without same-run terminal evidence", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, runId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 1, folded: 0 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("running"); + const [evaluation] = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluation?.originId).toBe(runId); + expect(evaluation?.parentId).toBeNull(); + }); + + it("still escalates when a same-run comment is followed by another actor marking the source done", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, issueId, runId, issuePrefix } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "in_progress", + sameRunTerminalEvidence: "comment", + }); + const completedAt = new Date(now.getTime() - 5 * 60_000); + await db + .update(issues) + .set({ status: "done", completedAt, updatedAt: completedAt }) + .where(eq(issues.id, issueId)); + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: "board-user", + agentId: null, + runId: null, + action: "issue.updated", + entityType: "issue", + entityId: issueId, + details: { + identifier: `${issuePrefix}-1`, + status: "done", + _previous: { status: "in_progress" }, + }, + createdAt: completedAt, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 1, folded: 0 }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.status).toBe("running"); + const [evaluation] = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluation?.originId).toBe(runId); + expect(evaluation?.parentId).toBeNull(); + }); + + it("folds existing evaluation and active watchdog recovery action idempotently", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId, managerId, issueId, runId, issuePrefix } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceStatus: "done", + sameRunTerminalEvidence: "activity", + }); + const evaluationIssueId = randomUUID(); + await db.insert(issues).values({ + id: evaluationIssueId, + companyId, + title: "Existing stale evaluation", + status: "todo", + priority: "high", + assigneeAgentId: managerId, + issueNumber: 2, + identifier: `${issuePrefix}-2`, + originKind: "stale_active_run_evaluation", + originId: runId, + originRunId: runId, + originFingerprint: `stale_active_run:${companyId}:${runId}`, + }); + await db.insert(issueRelations).values({ + companyId, + issueId: evaluationIssueId, + relatedIssueId: issueId, + type: "blocks", + }); + await db.insert(issueRecoveryActions).values({ + companyId, + sourceIssueId: issueId, + recoveryIssueId: evaluationIssueId, + kind: "active_run_watchdog", + status: "active", + ownerType: "agent", + ownerAgentId: managerId, + cause: "active_run_watchdog", + fingerprint: `active-run-watchdog:${companyId}:${runId}:${issueId}`, + evidence: { runId }, + nextAction: "Review stale active run", + }); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.scanSilentActiveRuns({ now, companyId }); + const second = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(first).toMatchObject({ created: 0, folded: 1 }); + expect(second).toMatchObject({ scanned: 0, created: 0, folded: 0 }); + const [evaluation] = await db.select().from(issues).where(eq(issues.id, evaluationIssueId)); + expect(evaluation?.status).toBe("done"); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run?.resultJson).toMatchObject({ + sourceResolvedWatchdogFold: { + sourceIssueId: issueId, + sourceIssueStatus: "done", + evaluationIssueId, + evaluationIssueIdentifier: `${issuePrefix}-2`, + }, + }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId)); + expect(action?.status).toBe("resolved"); + expect(action?.outcome).toBe("false_positive"); + const decisions = await db + .select() + .from(heartbeatRunWatchdogDecisions) + .where(eq(heartbeatRunWatchdogDecisions.runId, runId)); + expect(decisions).toHaveLength(1); + }); + + it("refuses recovery-on-recovery stale-run recursion", async () => { + const now = new Date("2026-04-22T20:00:00.000Z"); + const { companyId } = await seedRunningRun({ + now, + ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, + sourceOriginKind: "stale_active_run_evaluation", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.scanSilentActiveRuns({ now, companyId }); + + expect(result).toMatchObject({ created: 0, skipped: 1 }); + const evaluations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation"))); + expect(evaluations).toHaveLength(1); + }); + it("skips snoozed runs and healthy noisy runs", async () => { const now = new Date("2026-04-22T20:00:00.000Z"); const stale = await seedRunningRun({ diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 6cda7fb1..606f69bc 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -766,13 +766,19 @@ describe("heartbeat comment wake batching", () => { gateway.releaseFirstWait(); - await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(() => gateway.getAgentPayloads().length >= 2, 90_000); await waitFor(async () => { const runs = await db .select() .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); - return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + .where(eq(heartbeatRuns.agentId, agentId)) + .orderBy(asc(heartbeatRuns.createdAt)); + const [initialRun, promotedRun] = runs; + return ( + initialRun?.id === firstRun?.id && + initialRun.status === "succeeded" && + promotedRun?.status === "succeeded" + ); }, 90_000); const reopenedIssue = await db diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index f2560bcd..62b488dd 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -533,7 +533,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = .where(eq(heartbeatRuns.id, secondWake!.id)) .then((rows) => rows[0] ?? null); return run?.status === "succeeded"; - }); + }, 10_000); expect(secondRunSucceeded).toBe(true); expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2); } finally { diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index e6b2dc99..ecf70fa1 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -332,6 +332,82 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); }); + it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => { + await enableAutoRecovery(); + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const existingEscalationId = randomUUID(); + + await db.insert(issues).values({ + id: existingEscalationId, + companyId, + title: "Existing liveness unblock work", + status: "todo", + priority: "high", + parentId: blockerIssueId, + assigneeAgentId: managerId, + issueNumber: 5, + identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, + originKind: "harness_liveness_escalation", + originId: [ + "harness_liveness", + companyId, + blockedIssueId, + "in_review_without_action_path", + blockerIssueId, + ].join(":"), + }); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.findings).toBe(0); + expect(result.escalationsCreated).toBe(0); + expect(result.existingEscalations).toBe(0); + + const escalations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); + expect(escalations).toHaveLength(1); + }); + + it("keeps active invalid_review_participant recoveries from being retired", async () => { + await enableAutoRecovery(); + const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const existingEscalationId = randomUUID(); + + await db.insert(issues).values({ + id: existingEscalationId, + companyId, + title: "Existing invalid review participant unblock work", + status: "todo", + priority: "high", + parentId: blockedIssueId, + assigneeAgentId: managerId, + issueNumber: 5, + identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, + originKind: "harness_liveness_escalation", + originId: [ + "harness_liveness", + companyId, + blockedIssueId, + "invalid_review_participant", + blockerIssueId, + ].join(":"), + }); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.findings).toBe(0); + expect(result.escalationsCreated).toBe(0); + expect(result.existingEscalations).toBe(0); + + const escalations = await db + .select() + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); + expect(escalations).toHaveLength(1); + }); + it("creates one manager escalation, preserves blockers, and records owner selection", async () => { await enableAutoRecovery(); const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); @@ -724,4 +800,43 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); }); + + it("removes closed liveness escalations from blocker relations during reconciliation", async () => { + await enableAutoRecovery(); + const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); + const heartbeat = heartbeatService(db); + + const first = await heartbeat.reconcileIssueGraphLiveness(); + expect(first.escalationsCreated).toBe(1); + + const escalations = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, "harness_liveness_escalation"), + ), + ); + expect(escalations).toHaveLength(1); + + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, escalations[0]!.id)); + await db + .update(issues) + .set({ status: "done", blockedByIssueIds: [] }) + .where(eq(issues.id, blockerIssueId)); + + const second = await heartbeat.reconcileIssueGraphLiveness(); + expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0); + expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1); + + const blockers = await db + .select({ blockerIssueId: issueRelations.issueId }) + .from(issueRelations) + .where(eq(issueRelations.relatedIssueId, blockedIssueId)); + expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false); + }); }); diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts index 673297a1..145a6b6b 100644 --- a/server/src/__tests__/heartbeat-local-environment.test.ts +++ b/server/src/__tests__/heartbeat-local-environment.test.ts @@ -1,18 +1,12 @@ import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, - agentRuntimeState, - agentWakeupRequests, - activityLog, companies, - companySkills, createDb, environmentLeases, environments, - heartbeatRunEvents, - heartbeatRuns, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -73,16 +67,20 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { }, 20_000); afterEach(async () => { - await db.delete(environmentLeases); - await db.delete(environments); - await db.delete(activityLog); - await db.delete(heartbeatRunEvents); - await db.delete(heartbeatRuns); - await db.delete(agentWakeupRequests); - await db.delete(agentRuntimeState); - await db.delete(companySkills); - await db.delete(agents); - await db.delete(companies); + await db.execute(sql.raw(` + TRUNCATE TABLE + "environment_leases", + "environments", + "activity_log", + "heartbeat_run_events", + "heartbeat_runs", + "agent_wakeup_requests", + "agent_runtime_state", + "company_skills", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); }); afterAll(async () => { diff --git a/server/src/__tests__/heartbeat-model-profile.test.ts b/server/src/__tests__/heartbeat-model-profile.test.ts index 726436ca..5ac3c105 100644 --- a/server/src/__tests__/heartbeat-model-profile.test.ts +++ b/server/src/__tests__/heartbeat-model-profile.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { AdapterModelProfileDefinition } from "../adapters/index.js"; +import { + listAdapterModelProfiles, + type AdapterModelProfileDefinition, +} from "../adapters/index.js"; import { mergeModelProfileAdapterConfig, normalizeModelProfileWakeContext, @@ -17,6 +20,27 @@ const cheapProfile: AdapterModelProfileDefinition = { }; describe("heartbeat model profile application", () => { + it("uses the Codex local adapter cheap default when the agent has no runtime override", async () => { + const modelProfile = resolveModelProfileApplication({ + adapterModelProfiles: await listAdapterModelProfiles("codex_local"), + agentRuntimeConfig: {}, + issueModelProfile: "cheap", + contextSnapshot: {}, + }); + + expect(modelProfile).toMatchObject({ + requested: "cheap", + requestedBy: "issue_override", + applied: "cheap", + configSource: "adapter_default", + fallbackReason: null, + adapterConfig: { + model: "gpt-5.3-codex-spark", + modelReasoningEffort: "high", + }, + }); + }); + it("applies cheap profile patches before explicit issue adapter config overrides", () => { const modelProfile = resolveModelProfileApplication({ adapterModelProfiles: [cheapProfile], diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 58c86b96..8930b073 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -405,6 +405,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { includeIssue?: boolean; runErrorCode?: string | null; runError?: string | null; + contextSnapshot?: Record; }) { const companyId = randomUUID(); const agentId = randomUUID(); @@ -454,7 +455,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { triggerDetail: "system", status: input?.runStatus ?? "running", wakeupRequestId, - contextSnapshot: input?.includeIssue === false ? {} : { issueId }, + contextSnapshot: input?.includeIssue === false + ? input?.contextSnapshot ?? {} + : { ...(input?.contextSnapshot ?? {}), issueId }, processPid: input?.processPid ?? null, processGroupId: input?.processGroupId ?? null, processLossRetryCount: input?.processLossRetryCount ?? 0, @@ -765,7 +768,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { companyId: input.companyId, reason: "source_scoped_recovery_action", source: "assignment", - payload: expect.objectContaining({ modelProfile: "cheap" }), + payload: expect.objectContaining({ + modelProfile: "cheap", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }), }); const recoveryRun = recoveryWakeup?.runId @@ -783,6 +791,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { sourceIssueId: input.issueId, strandedRunId: input.runId, modelProfile: "cheap", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, }); await waitForHeartbeatIdle(db); const sourceIssue = await db @@ -920,6 +931,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { it("queues exactly one retry when the recorded local pid is dead", async () => { const { agentId, runId, issueId } = await seedRunFixture({ processPid: 999_999_999, + contextSnapshot: { + modelProfile: "cheap", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }, }); const heartbeat = heartbeatService(db); @@ -947,7 +964,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(retryRun?.status).toBe("queued"); expect(retryRun?.retryOfRunId).toBe(runId); expect(retryRun?.processLossRetryCount).toBe(1); - expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); const issue = await db .select() @@ -1253,8 +1270,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(retryRun?.scheduledRetryReason).toBe("transient_failure"); expect(retryRun?.contextSnapshot).toMatchObject({ codexTransientFallbackMode: "same_session", - modelProfile: "cheap", }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); const issue = await db .select() @@ -1789,9 +1806,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { payload: expect.objectContaining({ issueId, mutation: "assigned_todo_liveness_dispatch", - modelProfile: "cheap", }), }); + expect(wakeups[0]?.payload as Record).not.toHaveProperty("modelProfile"); const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); expect(runs).toHaveLength(1); @@ -1801,8 +1818,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { taskId: issueId, wakeReason: "issue_assigned", source: "issue.assigned_todo_liveness_dispatch", - modelProfile: "cheap", }); + expect(runs[0]?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); expect((runs[0]?.contextSnapshot as Record)?.retryReason).toBeUndefined(); const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); @@ -1909,9 +1926,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { payload: expect.objectContaining({ issueId: unblocked.issueId, mutation: "assigned_todo_liveness_dispatch", - modelProfile: "cheap", }), }); + expect(unblockedWakeups[0]?.payload as Record).not.toHaveProperty("modelProfile"); const unblockedRuns = await db .select() .from(heartbeatRuns) @@ -1963,7 +1980,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = runs.find((row) => row.id !== runId); expect(retryRun?.id).toBeTruthy(); expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("assignment_recovery"); - expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); if (retryRun) { await waitForRunToSettle(heartbeat, retryRun.id); } @@ -2002,8 +2019,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", retryOfRunId: runId, source: "issue.continuation_recovery", - modelProfile: "cheap", }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); const recoveries = await db .select() @@ -2054,7 +2071,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = runs.find((row) => row.id !== runId); expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("assignment_recovery"); - expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); if (retryRun) { await waitForRunToSettle(heartbeat, retryRun.id); } @@ -2296,7 +2313,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const retryRun = runs.find((row) => row.id !== runId); expect(retryRun?.id).toBeTruthy(); expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); - expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); if (retryRun) { await waitForRunToSettle(heartbeat, retryRun.id); } @@ -2786,8 +2803,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", retryOfRunId: runId, source: "issue.productive_terminal_continuation_recovery", - modelProfile: "cheap", }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId)); expect(wakeups).toHaveLength(2); @@ -2854,8 +2871,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", retryOfRunId: runId, source: "issue.productive_terminal_continuation_recovery", - modelProfile: "cheap", }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); }); it("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => { @@ -2910,8 +2927,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { retryReason: "issue_continuation_needed", retryOfRunId: runId, source: "issue.productive_terminal_continuation_recovery", - modelProfile: "cheap", }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); }); it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 55653be3..7ab6ed24 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -7,7 +7,7 @@ import { } from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { - it("overlays project env on top of agent env and unions secret keys", async () => { + it("overlays project and routine env on top of agent env and unions secret keys", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { @@ -29,29 +29,51 @@ describe("resolveExecutionRunAdapterConfig", () => { }, ], }); - const resolveEnvBindings = vi.fn().mockResolvedValue({ - env: { - SHARED_KEY: "project", - PROJECT_ONLY: "project-only", - }, - secretKeys: new Set(["PROJECT_SECRET"]), - manifest: [ - { - configPath: "env.PROJECT_SECRET", - envKey: "PROJECT_SECRET", - secretId: "secret-project", - secretKey: "project-secret", - version: 1, - provider: "local_encrypted", - outcome: "success", + const resolveEnvBindings = vi + .fn() + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "project", + PROJECT_ONLY: "project-only", }, - ], - }); + secretKeys: new Set(["PROJECT_SECRET"]), + manifest: [ + { + configPath: "env.PROJECT_SECRET", + envKey: "PROJECT_SECRET", + secretId: "secret-project", + secretKey: "project-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }) + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "routine", + ROUTINE_ONLY: "routine-only", + }, + secretKeys: new Set(["ROUTINE_SECRET"]), + manifest: [ + { + configPath: "env.ROUTINE_SECRET", + envKey: "ROUTINE_SECRET", + secretId: "secret-routine", + secretKey: "routine-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }); const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", executionRunConfig: { env: { SHARED_KEY: "agent" } }, projectEnv: { SHARED_KEY: "project" }, + routineEnv: { SHARED_KEY: "routine" }, + routineId: "routine-1", secretsSvc: { resolveAdapterConfigForRuntime, resolveEnvBindings, @@ -61,18 +83,88 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(result.resolvedConfig).toMatchObject({ other: "value", env: { - SHARED_KEY: "project", + SHARED_KEY: "routine", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", }, }); - expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]); + expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([ "secret-agent", "secret-project", + "secret-routine", ]); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); + expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only"); + expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ + consumerType: "routine", + consumerId: "routine-1", + }); + }); + + it("drops Paperclip runtime-owned env before resolving agent, project, and routine overlays", async () => { + const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ + config: { + ...config, + env: { ...(config.env as Record) }, + }, + secretKeys: new Set(), + manifest: [], + })); + const resolveEnvBindings = vi.fn(async (_companyId, env: Record) => ({ + env: Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + secretKeys: new Set(), + manifest: [], + })); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + executionRunConfig: { + env: { + PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" }, + PAPERCLIP_AGENT_ID: "spoofed-agent", + AGENT_ONLY: "agent-only", + }, + }, + projectEnv: { + PAPERCLIP_API_KEY: "project-api-key", + PAPERCLIP_COMPANY_ID: "spoofed-company", + PROJECT_ONLY: "project-only", + }, + routineEnv: { + PAPERCLIP_API_KEY: "routine-api-key", + PAPERCLIP_RUN_ID: "spoofed-run", + ROUTINE_ONLY: "routine-only", + }, + routineId: "routine-1", + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + } as any, + }); + + expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({ + env: { + AGENT_ONLY: "agent-only", + }, + }); + expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + PROJECT_ONLY: "project-only", + }); + expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ + ROUTINE_ONLY: "routine-only", + }); + expect(result.resolvedConfig.env).toEqual({ + AGENT_ONLY: "agent-only", + PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", + }); + expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_"); }); it("skips project env resolution when the project has no bindings", async () => { diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index e193dd3c..6a742264 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -286,8 +286,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { retryOfRunId: sourceRunId, scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure", - contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }), }); + expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString()); const earlyPromotion = await heartbeat.promoteDueScheduledRetries(new Date("2026-04-20T12:01:59.000Z")); diff --git a/server/src/__tests__/heartbeat-run-log.test.ts b/server/src/__tests__/heartbeat-run-log.test.ts index e88f976d..9eb6eda9 100644 --- a/server/src/__tests__/heartbeat-run-log.test.ts +++ b/server/src/__tests__/heartbeat-run-log.test.ts @@ -21,4 +21,21 @@ describe("compactRunLogChunk", () => { expect(compacted).toContain("[paperclip truncated run log chunk:"); expect(compacted.endsWith("tail")).toBe(true); }); + + it("redacts Paperclip credential shapes before persisting run-log chunks", () => { + const chunk = [ + "Authorization: Bearer live-bearer-token-value", + `export PAPERCLIP_API_KEY='paperclip-shell-secret'`, + `payload {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`, + "--paperclip-api-key=paperclip-flag-secret", + ].join("\n"); + + const compacted = compactRunLogChunk(chunk); + + expect(compacted).toContain("***REDACTED***"); + expect(compacted).not.toContain("live-bearer-token-value"); + expect(compacted).not.toContain("paperclip-shell-secret"); + expect(compacted).not.toContain("paperclip-json-secret"); + expect(compacted).not.toContain("paperclip-flag-secret"); + }); }); diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index f55ffb9e..eecf0bf2 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -2,21 +2,15 @@ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { - activityLog, agents, - agentRuntimeState, agentWakeupRequests, companies, - companySkills, createDb, documentRevisions, documents, - heartbeatRunEvents, heartbeatRuns, issueComments, issueDocuments, - issueRelations, - issueTreeHolds, issues, } from "@paperclipai/db"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; @@ -89,35 +83,39 @@ async function waitForCondition(fn: () => Promise, timeoutMs = 3_000) { } async function cleanupHeartbeatInvalidationFixture(db: ReturnType) { - for (let attempt = 0; attempt < 5; attempt += 1) { + for (let attempt = 0; attempt < 10; attempt += 1) { try { - await db.delete(companySkills); - await db.delete(issueComments); - await db.delete(issueDocuments); - await db.delete(documentRevisions); - await db.delete(documents); - await db.delete(issueRelations); - await db.delete(issueTreeHolds); - await db.delete(issues); - await db.delete(heartbeatRunEvents); - await db.delete(activityLog); - await db.delete(heartbeatRuns); - await db.delete(agentWakeupRequests); - await db.delete(agentRuntimeState); - await db.delete(agents); - await db.delete(companies); + await db.execute(sql.raw(` + TRUNCATE TABLE + "company_skills", + "issue_comments", + "issue_documents", + "document_revisions", + "documents", + "issue_relations", + "issue_tree_holds", + "issues", + "heartbeat_run_events", + "activity_log", + "heartbeat_runs", + "agent_wakeup_requests", + "agent_runtime_state", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); return; } catch (error) { const isLateCommentRace = error instanceof Error && error.message.includes("issue_comments_issue_id_issues_id_fk"); - if (!isLateCommentRace || attempt === 4) { + if (!isLateCommentRace || attempt === 9) { throw error; } // Heartbeat completion can write issue-thread comments shortly after the // run leaves queued/running. Retry the dependent deletes once those land. - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } } diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index fe7e2bef..3c74475e 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -322,6 +322,18 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(true); }); + it("resets session context for accepted planning confirmations that refresh workspace selection", () => { + expect( + shouldResetTaskSessionForWake({ + wakeReason: "issue_commented", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }), + ).toBe(true); + }); + it("does not reset session context on mention wake comment", () => { expect( shouldResetTaskSessionForWake({ diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 41e52190..5d324cd4 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -64,6 +64,7 @@ describe("instance settings routes", () => { mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -81,6 +82,7 @@ describe("instance settings routes", () => { experimental: { enableEnvironments: true, enableIsolatedWorkspaces: true, + enableCloudSync: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -123,6 +125,7 @@ describe("instance settings routes", () => { expect(getRes.body).toEqual({ enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts new file mode 100644 index 00000000..6669414d --- /dev/null +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { normalizeExperimentalSettings } from "../services/instance-settings.js"; + +describe("instance settings service", () => { + it("ignores retired experimental flags without resetting current settings", () => { + expect(normalizeExperimentalSettings({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: true, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 48, + enableNewestFirstIssueThread: true, + })).toEqual({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: true, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 48, + }); + }); +}); diff --git a/server/src/__tests__/invite-accept-existing-member.test.ts b/server/src/__tests__/invite-accept-existing-member.test.ts index 913fcdbf..660a854e 100644 --- a/server/src/__tests__/invite-accept-existing-member.test.ts +++ b/server/src/__tests__/invite-accept-existing-member.test.ts @@ -4,12 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; +const accessServiceMock = vi.hoisted(() => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalGrants: vi.fn(), +})); +const logActivityMock = vi.hoisted(() => vi.fn()); + vi.mock("../services/index.js", () => ({ - accessService: () => ({ - isInstanceAdmin: vi.fn(), - canUser: vi.fn(), - hasPermission: vi.fn(), - }), + accessService: () => accessServiceMock, agentService: () => ({ getById: vi.fn(), }), @@ -20,10 +25,36 @@ vi.mock("../services/index.js", () => ({ revokeBoardApiKey: vi.fn(), }), deduplicateAgentName: vi.fn(), - logActivity: vi.fn(), + logActivity: logActivityMock, notifyHireApproved: vi.fn(), })); +type QueryHooks = { + onSet?: (value: unknown) => void; + onValues?: (value: unknown) => void; +}; + +function createQuery(rows: unknown[], hooks: QueryHooks = {}) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + set: vi.fn((value: unknown) => { + hooks.onSet?.(value); + return query; + }), + values: vi.fn((value: unknown) => { + hooks.onValues?.(value); + return query; + }), + returning: vi.fn(() => query), + then(resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(rows).then(resolve, reject); + }, + }; + return query; +} + function createDbStub() { const updateMock = vi.fn(); const invite = { @@ -75,22 +106,26 @@ function createDbStub() { } function createApp(db: Record) { + return createAppWithActor(db, { + type: "board", + source: "session", + userId: "user-1", + companyIds: ["company-1"], + memberships: [ + { + companyId: "company-1", + membershipRole: "owner", + status: "active", + }, + ], + }); +} + +function createAppWithActor(db: Record, actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - source: "session", - userId: "user-1", - companyIds: ["company-1"], - memberships: [ - { - companyId: "company-1", - membershipRole: "owner", - status: "active", - }, - ], - }; + (req as any).actor = actor; next(); }); app.use( @@ -106,6 +141,162 @@ function createApp(db: Record) { return app; } +function createDirectHumanInviteDbStub() { + const insertedValues: unknown[] = []; + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "owner" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const createdJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const approvedJoinRequest = { + ...createdJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:02:00.000Z"), + updatedAt: new Date("2026-03-07T00:02:00.000Z"), + }; + const selectResponses = [ + [invite], + [{ email: "invitee@example.com" }], + [], + ]; + const updateResponses = [[], [approvedJoinRequest]]; + const insertResponses = [[createdJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert() { + return createQuery(insertResponses.shift() ?? [], { + onValues: (value) => insertedValues.push(value), + }); + }, + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, insertedValues, updateValues }; +} + +function createAcceptedHumanInviteReplayDbStub() { + const updateValues: unknown[] = []; + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: { human: { role: "operator" } }, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: "inviter-user", + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const pendingJoinRequest = { + id: "join-1", + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestIp: "::ffff:127.0.0.1", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + agentName: null, + adapterType: null, + capabilities: null, + agentDefaultsPayload: null, + claimSecretHash: null, + claimSecretExpiresAt: null, + claimSecretConsumedAt: null, + createdAgentId: null, + approvedByUserId: null, + approvedAt: null, + rejectedByUserId: null, + rejectedAt: null, + createdAt: new Date("2026-03-07T00:01:00.000Z"), + updatedAt: new Date("2026-03-07T00:01:00.000Z"), + }; + const replayedJoinRequest = { + ...pendingJoinRequest, + requestIp: "::ffff:127.0.0.1", + updatedAt: new Date("2026-03-07T00:06:00.000Z"), + }; + const approvedJoinRequest = { + ...replayedJoinRequest, + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: new Date("2026-03-07T00:07:00.000Z"), + updatedAt: new Date("2026-03-07T00:07:00.000Z"), + }; + const selectResponses = [ + [invite], + [pendingJoinRequest], + [{ email: "invitee@example.com" }], + [pendingJoinRequest], + ]; + const updateResponses = [[replayedJoinRequest], [approvedJoinRequest]]; + + const db = { + select() { + return createQuery(selectResponses.shift() ?? []); + }, + update() { + return createQuery(updateResponses.shift() ?? [], { + onSet: (value) => updateValues.push(value), + }); + }, + insert: vi.fn(), + transaction(callback: (tx: unknown) => unknown) { + return callback(db); + }, + }; + + return { db, updateValues }; +} + describe("POST /invites/:token/accept", () => { beforeEach(() => { vi.clearAllMocks(); @@ -123,4 +314,126 @@ describe("POST /invites/:token/accept", () => { expect(res.body.error).toBe("You already belong to this company"); expect(updateMock).not.toHaveBeenCalled(); }); + + it("grants company access immediately for a human invite", async () => { + const { db, insertedValues, updateValues } = createDirectHumanInviteDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(insertedValues).toEqual([ + expect.objectContaining({ + inviteId: "invite-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + requestingUserId: "invitee-user", + requestEmailSnapshot: "invitee@example.com", + }), + ]); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + acceptedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "owner", + "active", + ); + expect(accessServiceMock.setPrincipalGrants).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + expect.arrayContaining([ + expect.objectContaining({ permissionKey: "users:invite" }), + expect.objectContaining({ permissionKey: "users:manage_permissions" }), + ]), + "inviter-user", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); + + it("replays a consumed human invite for the same user and repairs company access", async () => { + const { db, updateValues } = createAcceptedHumanInviteReplayDbStub(); + const app = createAppWithActor(db, { + type: "board", + source: "session", + userId: "invitee-user", + companyIds: [], + memberships: [], + }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body.status).toBe("approved"); + expect(updateValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestIp: expect.any(String), + updatedAt: expect.any(Date), + }), + expect.objectContaining({ + status: "approved", + approvedByUserId: "inviter-user", + approvedAt: expect.any(Date), + }), + ]), + ); + expect(updateValues).not.toEqual( + expect.arrayContaining([expect.objectContaining({ acceptedAt: expect.any(Date) })]), + ); + expect(accessServiceMock.ensureMembership).toHaveBeenCalledWith( + "company-1", + "user", + "invitee-user", + "operator", + "active", + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.request_replayed", + entityId: "join-1", + details: expect.objectContaining({ inviteReplay: true }), + }), + ); + expect(logActivityMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "join.approved", + entityId: "join-1", + details: expect.objectContaining({ source: "human_invite_accept" }), + }), + ); + }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index fae007e0..6834f763 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -68,6 +68,7 @@ describe("human invite roles", () => { it("maps owner to the full management grant set", () => { expect(grantsForHumanRole("owner")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -75,6 +76,16 @@ describe("human invite roles", () => { ]); }); + it("maps admin to management grants including environment management", () => { + expect(grantsForHumanRole("admin")).toEqual([ + { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, + { permissionKey: "users:invite", scope: null }, + { permissionKey: "tasks:assign", scope: null }, + { permissionKey: "joins:approve", scope: null }, + ]); + }); + it("defaults legacy or missing roles to operator", () => { expect(normalizeHumanRole("member")).toBe("operator"); expect(resolveHumanInviteRole(null)).toBe("operator"); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index d2d57500..7d8407b6 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -39,7 +39,7 @@ describe("buildInviteOnboardingTextDocument", () => { allowedHostnames: [], }); - expect(text).toContain("Paperclip OpenClaw Gateway Onboarding"); + expect(text).toContain("Paperclip Agent Onboarding"); expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); @@ -48,14 +48,13 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); expect(text).toContain("paperclipApiUrl"); - expect(text).toContain("adapterType \"openclaw_gateway\""); + expect(text).toContain('"adapterType": "openclaw_gateway"'); expect(text).toContain("headers.x-openclaw-token"); expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); - expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json"); expect(text).toContain("PAPERCLIP_API_KEY"); - expect(text).toContain("saved token field"); - expect(text).toContain("Gateway token unexpectedly short"); + expect(text).toContain("Use your runtime's normal skill or instruction installation path."); + expect(text).toContain("Decide which Paperclip adapter type matches your runtime."); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 6a6c3b92..c11e2a43 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -106,6 +106,11 @@ function registerModuleMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index a4d1d9f7..7503bdf6 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -8,10 +8,13 @@ const companyId = "22222222-2222-4222-8222-222222222222"; const ownerAgentId = "33333333-3333-4333-8333-333333333333"; const peerAgentId = "44444444-4444-4444-8444-444444444444"; const ownerRunId = "55555555-5555-4555-8555-555555555555"; +const recoveryActionId = "77777777-7777-4777-8777-777777777777"; const mockIssueService = vi.hoisted(() => ({ addComment: vi.fn(), assertCheckoutOwner: vi.fn(), + create: vi.fn(), + createChild: vi.fn(), getAttachmentById: vi.fn(), getByIdentifier: vi.fn(), getById: vi.fn(), @@ -27,6 +30,7 @@ const mockIssueService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -45,7 +49,9 @@ const mockDocumentService = vi.hoisted(() => ({ })); const mockWorkProductService = vi.hoisted(() => ({ + createForIssue: vi.fn(), getById: vi.fn(), + remove: vi.fn(), update: vi.fn(), })); @@ -62,6 +68,14 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({ })); const mockIssueRecoveryActionService = vi.hoisted(() => ({ getActiveForIssue: vi.fn(async () => null), + resolveActiveForIssue: vi.fn(async () => null), +})); +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), })); function registerRouteMocks() { @@ -109,13 +123,7 @@ function registerRouteMocks() { saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), }), 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), - }), + heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => ({ get: vi.fn(async () => ({ id: "instance-settings-1", @@ -184,7 +192,26 @@ function makeAgent(id: string, overrides: Record = {}) { }; } -async function createApp(actor: Record) { +function createRunContextDb(contextSnapshot: Record = {}) { + return { + transaction: async (callback: (tx: Record) => Promise) => callback({}), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + then: async (resolve: (rows: unknown[]) => unknown) => + resolve([{ + id: ownerRunId, + companyId, + agentId: ownerAgentId, + contextSnapshot, + }]), + })), + })), + })), + }; +} + +async function createApp(actor: Record, db: unknown = createRunContextDb()) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), @@ -195,7 +222,7 @@ async function createApp(actor: Record) { (req as any).actor = actor; next(); }); - app.use("/api", issueRoutes({} as any, mockStorageService as any)); + app.use("/api", issueRoutes(db as any, mockStorageService as any)); app.use(errorHandler); return app; } @@ -249,6 +276,13 @@ describe("agent issue mutation checkout ownership", () => { registerRouteMocks(); vi.clearAllMocks(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action === "tasks:assign", + action: input.action, + reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant", + explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.", + })); mockAccessService.hasPermission.mockReset(); mockAgentService.getById.mockReset(); mockAgentService.list.mockReset(); @@ -256,6 +290,8 @@ describe("agent issue mutation checkout ownership", () => { mockCompanyService.getById.mockReset(); mockIssueService.addComment.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); + mockIssueService.create.mockReset(); + mockIssueService.createChild.mockReset(); mockIssueService.getAttachmentById.mockReset(); mockIssueService.getByIdentifier.mockReset(); mockIssueService.getById.mockReset(); @@ -265,12 +301,53 @@ describe("agent issue mutation checkout ownership", () => { mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null); + mockIssueRecoveryActionService.resolveActiveForIssue.mockReset(); + mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + companyId, + sourceIssueId: issueId, + recoveryIssueId: null, + kind: "issue_graph_liveness", + status: "resolved", + ownerType: "agent", + ownerAgentId, + ownerUserId: null, + previousOwnerAgentId: null, + returnOwnerAgentId: null, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:test", + evidence: {}, + nextAction: "Restore a live execution path.", + wakePolicy: null, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: null, + timeoutAt: null, + lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"), + outcome: "restored", + resolutionNote: "Resolved by recovery owner", + resolvedAt: new Date("2026-05-13T18:05:00.000Z"), + createdAt: new Date("2026-05-13T17:55:00.000Z"), + updatedAt: new Date("2026-05-13T18:05:00.000Z"), + }); + mockHeartbeatService.wakeup.mockReset(); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockReset(); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockReset(); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockReset(); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockReset(); + mockHeartbeatService.cancelRun.mockResolvedValue(null); mockIssueService.remove.mockReset(); mockIssueService.removeAttachment.mockReset(); mockIssueService.update.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockDocumentService.upsertIssueDocument.mockReset(); + mockWorkProductService.createForIssue.mockReset(); mockWorkProductService.getById.mockReset(); + mockWorkProductService.remove.mockReset(); mockWorkProductService.update.mockReset(); mockStorageService.putFile.mockReset(); mockStorageService.getObject.mockReset(); @@ -292,6 +369,28 @@ describe("agent issue mutation checkout ownership", () => { mockIssueService.getById.mockResolvedValue(makeIssue()); mockIssueService.getByIdentifier.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockIssueService.create.mockImplementation(async (_companyId: string, input: Record) => ({ + ...makeIssue({ + id: "88888888-8888-4888-8888-888888888888", + status: "todo", + assigneeAgentId: null, + }), + ...input, + companyId, + })); + mockIssueService.createChild.mockImplementation(async (_parentId: string, input: Record) => ({ + issue: { + ...makeIssue({ + id: "99999999-9999-4999-8999-999999999999", + status: "todo", + parentId: issueId, + assigneeAgentId: null, + }), + ...input, + companyId, + }, + parentBlockerAdded: false, + })); mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); @@ -333,6 +432,14 @@ describe("agent issue mutation checkout ownership", () => { latestRevisionNumber: 2, }, }); + mockWorkProductService.createForIssue.mockResolvedValue({ + id: "product-2", + issueId, + companyId, + type: "artifact", + provider: "test", + title: "Artifact", + }); mockWorkProductService.getById.mockResolvedValue({ id: "product-1", issueId, @@ -346,6 +453,12 @@ describe("agent issue mutation checkout ownership", () => { type: "artifact", title: "Updated", }); + mockWorkProductService.remove.mockResolvedValue({ + id: "product-1", + issueId, + companyId, + type: "artifact", + }); mockStorageService.putFile.mockResolvedValue({ provider: "local_disk", objectKey: "issues/upload.txt", @@ -410,10 +523,158 @@ describe("agent issue mutation checkout ownership", () => { key: "plan", createdByAgentId: ownerAgentId, createdByRunId: ownerRunId, + lockedDocumentStrategy: "create_new_document", }), ); }); + it.each([ + [ + "work product create", + (app: express.Express) => + request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "artifact", + provider: "test", + title: "Artifact", + }), + ], + ["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })], + ["work product delete", (app: express.Express) => request(app).delete("/api/work-products/product-1")], + [ + "attachment upload", + (app: express.Express) => + request(app) + .post(`/api/companies/${companyId}/issues/${issueId}/attachments`) + .attach("file", Buffer.from("report"), { filename: "report.txt", contentType: "text/plain" }), + ], + ["attachment delete", (app: express.Express) => request(app).delete("/api/attachments/attachment-1")], + ])("blocks cheap status-only recovery runs from %s", async (_name, sendRequest) => { + const app = await createApp( + ownerActor(), + createRunContextDb({ + modelProfile: "cheap", + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }), + ); + + const res = await sendRequest(app); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Cheap status-only recovery runs cannot update issue documents"); + expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId); + expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled(); + expect(mockWorkProductService.update).not.toHaveBeenCalled(); + expect(mockWorkProductService.remove).not.toHaveBeenCalled(); + expect(mockStorageService.putFile).not.toHaveBeenCalled(); + expect(mockStorageService.deleteObject).not.toHaveBeenCalled(); + expect(mockIssueService.removeAttachment).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "issue create", + (app: express.Express) => + request(app).post(`/api/companies/${companyId}/issues`).send({ + title: "Downstream source work", + assigneeAdapterOverrides: { modelProfile: "cheap" }, + }), + ], + [ + "child issue create", + (app: express.Express) => + request(app).post(`/api/issues/${issueId}/children`).send({ + title: "Downstream child source work", + assigneeAdapterOverrides: { modelProfile: "cheap" }, + }), + ], + [ + "issue update", + (app: express.Express) => + request(app).patch(`/api/issues/${issueId}`).send({ + assigneeAdapterOverrides: { modelProfile: "cheap" }, + }), + ], + ])("blocks cheap status-only recovery runs from propagating cheap profile through %s", async (_name, sendRequest) => { + const app = await createApp( + ownerActor(), + createRunContextDb({ + modelProfile: "cheap", + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }), + ); + + const res = await sendRequest(app); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("cannot assign downstream issue work to the cheap model profile"); + expect(mockIssueService.create).not.toHaveBeenCalled(); + expect(mockIssueService.createChild).not.toHaveBeenCalled(); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("allows board users to set explicit cheap issue assignee profile overrides", async () => { + const app = await createApp(boardActor()); + + await request(app) + .patch(`/api/issues/${issueId}`) + .send({ assigneeAdapterOverrides: { modelProfile: "cheap" } }) + .expect(200); + + expect(mockIssueService.update).toHaveBeenCalledWith( + issueId, + expect.objectContaining({ + assigneeAdapterOverrides: { modelProfile: "cheap" }, + }), + ); + }); + + it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => { + const app = await createApp(ownerActor()); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .patch(`/api/issues/${issueId}`) + .send({ title: "Updated after commit" }) + .expect(200); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .post(`/api/issues/${issueId}/comments`) + .send({ body: "progress update" }) + .expect(201); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .put(`/api/issues/${issueId}/documents/plan`) + .send({ format: "markdown", body: "# updated" }) + .expect(200); + + mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed")); + await request(app) + .patch("/api/work-products/product-1") + .send({ title: "Updated product" }) + .expect(200); + + expect(mockIssueService.update).toHaveBeenCalledWith( + issueId, + expect.objectContaining({ title: "Updated after commit" }), + ); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + issueId, + "progress update", + expect.any(Object), + expect.any(Object), + ); + expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled(); + expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" }); + }); + it("preserves board mutations on active checkouts", async () => { const app = await createApp(boardActor()); @@ -429,12 +690,12 @@ describe("agent issue mutation checkout ownership", () => { }); it("allows agents with the active-checkout management grant to mutate active checkouts", async () => { - mockAccessService.hasPermission.mockImplementation(async ( - _companyId: string, - _principalType: string, - principalId: string, - permissionKey: string, - ) => principalId === peerAgentId && permissionKey === "tasks:manage_active_checkouts"); + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action === "tasks:manage_active_checkouts", + action: input.action, + reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant", + explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.", + })); const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" }); @@ -476,4 +737,136 @@ describe("agent issue mutation checkout ownership", () => { title: "Claimable update", }); }); + + it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("rejects peer-agent recovery resolution on a board-owned source issue", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(peerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "done", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action"); + expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled(); + }); + + it("allows the named recovery owner to resolve a board-owned source issue", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }), + ...patch, + })); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(ownerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "done", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled(); + }); + + it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => { + mockIssueService.getById.mockResolvedValue( + makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), + ); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }), + ...patch, + })); + mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({ + id: recoveryActionId, + ownerAgentId, + }); + + const res = await request(await createApp(ownerActor())) + .post(`/api/issues/${issueId}/recovery-actions/resolve`) + .send({ + actionId: recoveryActionId, + outcome: "restored", + sourceIssueStatus: "todo", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ownerAgentId, + expect.objectContaining({ + reason: "issue_recovery_action_restored", + payload: expect.objectContaining({ + issueId, + recoveryActionId, + mutation: "recovery_action_resolution", + }), + }), + ); + }); + + it("uses the authorization decision path for assignment changes", async () => { + const decide = vi.fn(async () => ({ + allowed: false, + action: "tasks:assign", + reason: "deny_policy_restricted", + explanation: "Target agent requires approval before task assignment.", + })); + (mockAccessService as any).decide = decide; + mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId })); + mockAgentService.resolveByReference.mockResolvedValue({ + ambiguous: false, + agent: makeAgent(peerAgentId), + }); + + const app = await createApp(ownerActor()); + const res = await request(app) + .patch(`/api/issues/${issueId}`) + .send({ assigneeAgentId: peerAgentId }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("requires approval"); + expect(decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "tasks:assign", + resource: expect.objectContaining({ + type: "issue", + companyId, + issueId, + assigneeAgentId: peerAgentId, + }), + })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index a251181e..bd196a60 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -22,6 +22,12 @@ const mockIssueService = vi.hoisted(() => ({ vi.mock("../services/index.js", () => ({ accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ @@ -76,6 +82,11 @@ vi.mock("../services/index.js", () => ({ syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({ diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 6a737903..39380a1b 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -81,6 +81,11 @@ function registerRouteMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 12181719..caf6445f 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -116,6 +116,11 @@ function registerServiceMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 8a55d9db..ad4ee770 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -8,6 +8,7 @@ const mockIssueService = vi.hoisted(() => ({ update: vi.fn(), addComment: vi.fn(), getDependencyReadiness: vi.fn(), + getCurrentScheduledRetry: vi.fn(), findMentionedAgents: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), @@ -15,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), + decide: vi.fn(), hasPermission: vi.fn(), })); @@ -223,10 +225,12 @@ describe.sequential("issue comment reopen routes", () => { mockIssueService.update.mockReset(); mockIssueService.addComment.mockReset(); mockIssueService.getDependencyReadiness.mockReset(); + mockIssueService.getCurrentScheduledRetry.mockReset(); mockIssueService.findMentionedAgents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockAccessService.canUser.mockReset(); + mockAccessService.decide.mockReset(); mockAccessService.hasPermission.mockReset(); mockHeartbeatService.wakeup.mockReset(); mockHeartbeatService.reportRunActivity.mockReset(); @@ -300,10 +304,20 @@ describe.sequential("issue comment reopen routes", () => { allBlockersDone: true, isDependencyReady: true, }); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => { + const allowed = input.action !== "tasks:manage_active_checkouts"; + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : "Missing active checkout override.", + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); mockAgentService.getById.mockResolvedValue(null); mockAgentService.list.mockResolvedValue([ @@ -564,6 +578,128 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("moves in-progress issues with a scheduled retry back to todo via POST human comments", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "retry-run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "I added the missing detail; please continue." }); + + expect(res.status).toBe(201); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { status: "todo" }, + ); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + status: "todo", + scheduledRetrySupersededByComment: true, + scheduledRetryRunId: "retry-run-1", + cancelledScheduledRetryRunId: "retry-run-1", + }), + }), + ); + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + commentId: "comment-1", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + wakeReason: "issue_commented", + source: "issue.comment", + }), + }), + )); + }); + + it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed")); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "I added the missing detail; please continue." }); + + expect(res.status).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.updated" }), + ); + }); + + it("keeps ordinary in-progress POST human comments in progress when no scheduled retry exists", async () => { + const issue = makeIssue("in_progress"); + mockIssueService.getById.mockResolvedValue(issue); + + const res = await request(await installActor(createApp())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "Checking in without retry state." }); + + expect(res.status).toBe(201); + expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_commented", + }), + )); + }); + it("passes validated comment presentation fields to trusted board comment writes", async () => { const app = await installActor(createApp()); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); @@ -727,6 +863,96 @@ describe.sequential("issue comment reopen routes", () => { )); }); + it("moves in-progress issues with a scheduled retry back to todo via the PATCH comment path", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "retry-run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "Retry window is over; please continue." }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + status: "todo", + actorAgentId: null, + actorUserId: "local-board", + }), + ); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + reason: "issue_commented", + payload: expect.objectContaining({ + commentId: "comment-1", + mutation: "comment", + }), + }), + )); + }); + + it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "retry-run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue({ + runId: "retry-run-1", + status: "scheduled_retry", + agentId: "22222222-2222-4222-8222-222222222222", + agentName: "CodexCoder", + retryOfRunId: "source-run-1", + scheduledRetryAt: new Date("2026-05-18T14:00:00.000Z"), + scheduledRetryAttempt: 1, + scheduledRetryReason: "transient_failure", + error: null, + errorCode: null, + }); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("cancel failed")); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "Retry window is over; please continue." }); + + expect(res.status).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.updated" }), + ); + }); + it("rejects non-assignee agent PATCH comments on closed issues", async () => { mockIssueService.getById.mockResolvedValue(makeIssue("done")); mockIssueService.addComment.mockResolvedValue({ diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index 4abb18ee..d95d446c 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -65,6 +65,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({ diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 3938e06a..4d4dfcd6 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -146,7 +146,34 @@ function registerModuleMocks() { })); } -async function createApp() { +function createRunContextDb(contextSnapshot: Record) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + then: async (resolve: (rows: unknown[]) => unknown) => + resolve([{ + id: "run-1", + companyId, + agentId: "agent-1", + contextSnapshot, + }]), + })), + })), + })), + }; +} + +async function createApp( + actor: Express.Request["actor"] = { + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: false, + }, + db: unknown = {}, +) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), @@ -154,16 +181,10 @@ async function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = { - type: "board", - userId: "board-user", - companyIds: [companyId], - source: "local_implicit", - isInstanceAdmin: false, - }; + (req as any).actor = actor; next(); }); - app.use("/api", issueRoutes({} as any, {} as any)); + app.use("/api", issueRoutes(db as any, {} as any)); app.use(errorHandler); return app; } @@ -315,6 +336,40 @@ describe("issue document revision routes", () => { })); }); + it("blocks cheap status-only recovery runs from restoring issue documents", async () => { + mockIssueService.getById.mockResolvedValueOnce({ + id: issueId, + companyId, + identifier: "PAP-881", + title: "Document revisions", + status: "todo", + assigneeAgentId: "agent-1", + }); + + const res = await request(await createApp( + { + type: "agent", + agentId: "agent-1", + companyId, + runId: "run-1", + source: "agent_jwt", + }, + createRunContextDb({ + modelProfile: "cheap", + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }), + )) + .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Cheap status-only recovery runs cannot update issue documents"); + expect(mockDocumentsService.restoreIssueDocumentRevision).not.toHaveBeenCalled(); + }); + it("rejects invalid document keys before attempting restore", async () => { const res = await request(await createApp()) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 1b0db770..0b7392d2 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -26,6 +26,7 @@ const mockHeartbeatService = vi.hoisted(() => ({ const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(async () => false), + decide: vi.fn(), hasPermission: vi.fn(async () => false), })); @@ -160,6 +161,17 @@ describe("issue execution policy routes", () => { parentBlockerAdded: false, }); mockAccessService.canUser.mockResolvedValue(false); + mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => { + const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit" + ? true + : Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission()); + return { + allowed, + action: input.action, + reason: allowed ? "allow_explicit_grant" : "deny_missing_grant", + explanation: allowed ? "Allowed by test grant." : `Missing permission: ${input.action ?? "action"}`, + }; + }); mockAccessService.hasPermission.mockResolvedValue(false); }); diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index cc533998..dd15e7c0 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -5,9 +5,13 @@ import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, + agentWakeupRequests, activityLog, companies, createDb, + environmentLeases, + environments, + heartbeatRuns, issueComments, issueRecoveryActions, issueRelations, @@ -130,7 +134,11 @@ describeEmbeddedPostgres("issue recovery actions", () => { afterEach(async () => { await db.delete(issueRecoveryActions); await db.delete(issueComments); + await db.delete(environmentLeases); await db.delete(activityLog); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(environments); await db.delete(issues); await db.delete(agents); await db.delete(companies); @@ -191,6 +199,24 @@ describeEmbeddedPostgres("issue recovery actions", () => { return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! }; } + async function seedHeartbeatRun(input: { + companyId: string; + agentId: string; + runId: string; + issueId?: string; + status?: string; + }) { + await db.insert(heartbeatRuns).values({ + id: input.runId, + companyId: input.companyId, + agentId: input.agentId, + invocationSource: "manual", + status: input.status ?? "running", + startedAt: new Date("2026-05-13T18:00:00.000Z"), + contextSnapshot: input.issueId ? { issueId: input.issueId } : undefined, + }); + } + function createApp(actor: any = { type: "board", source: "local_implicit" }) { const app = express(); app.use(express.json()); @@ -545,6 +571,393 @@ describeEmbeddedPostgres("issue recovery actions", () => { ); }); + it("resolves an active recovery action by returning the source issue to todo", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:try-again", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + const resolved = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "todo", + resolutionNote: "Try the source issue again.", + }) + .expect(200); + + expect(resolved.body.issue).toMatchObject({ + id: sourceIssueId, + status: "todo", + activeRecoveryAction: null, + }); + expect(resolved.body.recoveryAction).toMatchObject({ + id: action.id, + status: "resolved", + outcome: "restored", + resolutionNote: "Try the source issue again.", + }); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + }); + + it("marks a recovery action stale when a blocked source issue is manually moved to todo", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:manual-restore", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + const patched = await request(app) + .patch(`/api/issues/${sourceIssueId}`) + .send({ status: "todo" }) + .expect(200); + + expect(patched.body).toMatchObject({ + id: sourceIssueId, + status: "todo", + activeRecoveryAction: null, + }); + + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.", + }); + expect(actionRow?.resolvedAt).toBeTruthy(); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + expect(detail.body.activeRecoveryAction).toBeNull(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.map((row) => row.action)).toEqual( + expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]), + ); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "issue_update", + }); + }); + + it("folds stale recovery during read projection after the source issue reaches done", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:done-projection", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + await db.update(issues).set({ status: "done" }).where(eq(issues.id, sourceIssueId)); + const app = createApp(); + + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + + expect(detail.body).toMatchObject({ + id: sourceIssueId, + status: "done", + activeRecoveryAction: null, + }); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue reached done.", + }); + expect(actionRow?.resolvedAt).toBeTruthy(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "read_projection", + recoveryActionId: action.id, + }); + }); + + it("keeps active recovery visible when a plain comment does not create a live path", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:plain-comment", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + await request(app) + .post(`/api/issues/${sourceIssueId}/comments`) + .send({ body: "I am looking at this, but not changing the disposition." }) + .expect(201); + + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({ + id: action.id, + status: "active", + }); + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + expect(detail.body.activeRecoveryAction).toMatchObject({ id: action.id }); + }); + + it("folds stale recovery when a structured resume comment restores todo dispatch", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:resume-comment", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + await request(app) + .post(`/api/issues/${sourceIssueId}/comments`) + .send({ body: "Resume this now.", resume: true }) + .expect(201); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("todo"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "cancelled", + outcome: "cancelled", + resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.", + }); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({ + source: "source_revalidation", + trigger: "comment", + recoveryActionId: action.id, + }); + }); + + it("rejects peer-agent source issue updates that would hide another owner's recovery action", async () => { + const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:peer-status-update", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp({ + type: "agent", + agentId: coderId, + companyId, + runId: randomUUID(), + source: "agent_jwt", + }); + + await request(app) + .patch(`/api/issues/${sourceIssueId}`) + .send({ status: "todo" }) + .expect(403); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("blocked"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "active", + outcome: null, + resolvedAt: null, + }); + }); + + it("rejects peer-agent recovery action resolution on a board-owned source issue", async () => { + const { companyId, managerId, coderId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:peer-resolution", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const app = createApp({ + type: "agent", + agentId: coderId, + companyId, + runId: randomUUID(), + source: "agent_jwt", + }); + + await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + resolutionNote: "Peer agent should not be able to clear this recovery.", + }) + .expect(403); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("blocked"); + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "active", + outcome: null, + resolvedAt: null, + }); + }); + + it("allows the named recovery owner to resolve a board-owned source recovery action", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + await db + .update(issues) + .set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }) + .where(eq(issues.id, sourceIssueId)); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:owner-resolution", + evidence: { latestIssueStatus: "blocked" }, + nextAction: "Restore a live execution path.", + wakePolicy: { type: "manual" }, + }); + const runId = randomUUID(); + const app = createApp({ + type: "agent", + agentId: managerId, + companyId, + runId, + source: "agent_jwt", + }); + await seedHeartbeatRun({ + companyId, + agentId: managerId, + runId, + issueId: sourceIssueId, + }); + + const resolved = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + resolutionNote: "Recovery owner verified the work was intentionally completed.", + }) + .expect(200); + + expect(resolved.body.issue).toMatchObject({ + id: sourceIssueId, + status: "done", + activeRecoveryAction: null, + }); + expect(resolved.body.recoveryAction).toMatchObject({ + id: action.id, + status: "resolved", + outcome: "restored", + }); + }); + it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => { const { companyId, managerId, sourceIssueId } = await seedCompany(); const recoveryActionSvc = issueRecoveryActionService(db); diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 0fbf303c..634ff800 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -58,6 +58,11 @@ function registerModuleMocks() { syncDocument: async () => undefined, syncIssue: async () => undefined, }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index a5d9acd8..785dbf18 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -16,6 +16,7 @@ const mockInteractionService = vi.hoisted(() => ({ acceptSuggestedTasks: vi.fn(), rejectInteraction: vi.fn(), rejectSuggestedTasks: vi.fn(), + expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(), answerQuestions: vi.fn(), cancelQuestions: vi.fn(), })); @@ -42,6 +43,12 @@ function registerModuleMocks() { }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ @@ -106,6 +113,7 @@ function createIssue(overrides: Record = {}) { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "in_progress", + workMode: "standard", priority: "medium", projectId: null, goalId: null, @@ -155,6 +163,7 @@ describe.sequential("issue thread interaction routes", () => { vi.clearAllMocks(); mockIssueService.getById.mockResolvedValue(createIssue()); mockInteractionService.listForIssue.mockResolvedValue([]); + mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValue([]); mockInteractionService.create.mockResolvedValue({ id: "interaction-1", companyId: "company-1", @@ -287,6 +296,18 @@ describe.sequential("issue thread interaction routes", () => { }); it("lists and creates board-authored interactions", async () => { + mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([ + { + id: "interaction-expired", + kind: "request_confirmation", + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }, + }, + ]); mockInteractionService.listForIssue.mockResolvedValue([ { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, ]); @@ -297,6 +318,24 @@ describe.sequential("issue thread interaction routes", () => { expect(listRes.body).toEqual([ { id: "interaction-1", kind: "suggest_tasks", status: "pending" }, ]); + expect(mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments).toHaveBeenCalledWith( + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.thread_interaction_expired", + details: expect.objectContaining({ + interactionId: "interaction-expired", + interactionKind: "request_confirmation", + source: "issue.interactions.catchup_superseded_by_comment", + result: expect.objectContaining({ + outcome: "superseded_by_comment", + commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }), + }), + }), + ); const createRes = await request(app) .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions") @@ -481,6 +520,57 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-plan", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: "confirmation:issue:plan:revision", + sourceCommentId: null, + sourceRunId: "run-plan", + payload: { + version: 1, + prompt: "Approve this plan?", + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-plan/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + contextSnapshot: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-plan", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }), + }), + ); + }); + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "in_review", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index f1f0be38..d26c4fe4 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -9,6 +9,7 @@ import { documents, goals, heartbeatRuns, + issueComments, issueDocuments, instanceSettings, issueRelations, @@ -41,6 +42,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { afterEach(async () => { await db.delete(issueThreadInteractions); + await db.delete(issueComments); await db.delete(issueDocuments); await db.delete(documentRevisions); await db.delete(documents); @@ -57,6 +59,37 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { await tempDb?.cleanup(); }); + async function seedConfirmationIssue(title = "Comment supersede") { + const companyId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); + await db.insert(goals).values({ + id: goalId, + companyId, + title, + level: "task", + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + }); + + return { companyId, goalId, issueId }; + } + it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => { const companyId = randomUUID(); const goalId = randomUUID(); @@ -783,35 +816,10 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("expires supersedable request confirmations when a user comments", async () => { - const companyId = randomUUID(); - const goalId = randomUUID(); - const issueId = randomUUID(); + it("expires request confirmations opted into user-comment supersede after creation", async () => { + const { companyId, issueId } = await seedConfirmationIssue(); const commentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); - await db.insert(goals).values({ - id: goalId, - companyId, - title: "Comment supersede", - level: "task", - status: "active", - }); - await db.insert(issues).values({ - id: issueId, - companyId, - goalId, - title: "Parent issue", - status: "in_progress", - priority: "medium", - }); - const created = await interactionsSvc.create({ id: issueId, companyId, @@ -831,6 +839,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { companyId, }, { id: commentId, + createdAt: new Date(new Date(created.createdAt).getTime() + 1_000), authorUserId: "local-board", }, { userId: "local-board", @@ -849,6 +858,160 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("keeps request confirmations pending unless user-comment supersede is explicitly enabled", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out"); + + await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + }, + }, { + userId: "local-board", + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(Date.now() + 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + }); + + expect(expired).toHaveLength(0); + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("does not supersede request confirmations for agent, system, or older user comments", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Comment supersede exclusions"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, + }, + }, { + userId: "local-board", + }); + const createdAtMs = new Date(created.createdAt).getTime(); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, { + agentId: randomUUID(), + })).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs + 1_000), + authorUserId: null, + }, {})).resolves.toHaveLength(0); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({ + id: issueId, + companyId, + }, { + id: randomUUID(), + createdAt: new Date(createdAtMs - 1_000), + authorUserId: "local-board", + }, { + userId: "local-board", + })).resolves.toHaveLength(0); + + const rows = await db.select().from(issueThreadInteractions); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("pending"); + }); + + it("repairs historical request confirmations superseded by later user comments idempotently", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Historical comment supersede"); + const commentId = randomUUID(); + const createdAt = new Date("2026-05-18T12:00:00.000Z"); + + const created = await interactionsSvc.create({ + id: issueId, + companyId, + }, { + kind: "request_confirmation", + payload: { + version: 1, + prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, + }, + }, { + userId: "local-board", + }); + await db + .update(issueThreadInteractions) + .set({ createdAt, updatedAt: createdAt }) + .where(eq(issueThreadInteractions.id, created.id)); + + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorType: "system", + body: "System-side progress note.", + createdAt: new Date("2026-05-18T12:00:30.000Z"), + updatedAt: new Date("2026-05-18T12:00:30.000Z"), + }); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + body: "Please revise this first.", + createdAt: new Date("2026-05-18T12:01:00.000Z"), + updatedAt: new Date("2026-05-18T12:01:00.000Z"), + }); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + }); + + expect(expired).toHaveLength(1); + expect(expired[0]).toMatchObject({ + id: created.id, + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId, + }, + resolvedByAgentId: null, + resolvedByUserId: "local-board", + }); + + await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({ + id: issueId, + companyId, + })).resolves.toEqual([]); + }); + it("expires request confirmations when the watched issue document revision changes", async () => { const companyId = randomUUID(); const goalId = randomUUID(); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index b0f04f97..42539b21 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -12,6 +12,7 @@ const mockIssueService = vi.hoisted(() => ({ getRelationSummaries: vi.fn(), listWakeableBlockedDependents: vi.fn(), getWakeableParentAfterChildCompletion: vi.fn(), + getCurrentScheduledRetry: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ @@ -32,6 +33,12 @@ vi.mock("../services/index.js", () => ({ }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ @@ -94,6 +101,12 @@ function registerModuleMocks() { }), accessService: () => ({ canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), hasPermission: vi.fn(async () => true), }), agentService: () => ({ @@ -205,6 +218,7 @@ describe("issue update comment wakeups", () => { mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); }); it("includes the new comment in assignment wakes from issue updates", async () => { diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index e23c440d..c713bfa6 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -119,6 +119,11 @@ function registerRouteMocks() { getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 2bdd6b1a..5dc27d96 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -115,6 +115,11 @@ vi.mock("../services/index.js", () => ({ getActiveForIssue: vi.fn(async () => null), listActiveForIssues: vi.fn(async () => new Map()), }), + issueThreadInteractionService: () => ({ + listForIssue: vi.fn(async () => []), + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + }), issueReferenceService: () => mockIssueReferenceService, issueService: () => mockIssueService, logActivity: mockLogActivity, diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 44ac15e6..150625c3 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -380,6 +380,46 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); }); + it("can page issues by most recently updated before priority", async () => { + const companyId = randomUUID(); + const oldCriticalIssueId = randomUUID(); + const recentMediumIssueId = 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: oldCriticalIssueId, + companyId, + title: "Old critical issue", + status: "todo", + priority: "critical", + updatedAt: new Date("2026-05-01T10:00:00.000Z"), + }, + { + id: recentMediumIssueId, + companyId, + title: "Recent medium issue", + status: "todo", + priority: "medium", + updatedAt: new Date("2026-05-17T21:12:29.993Z"), + }, + ]); + + const result = await svc.list(companyId, { + limit: 1, + sortField: "updated", + sortDir: "desc", + }); + + expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]); + }); + it("ranks comment matches ahead of description-only matches", async () => { const companyId = randomUUID(); const commentMatchId = randomUUID(); @@ -733,6 +773,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const normalIssueId = randomUUID(); const pluginVisibleIssueId = randomUUID(); const operationIssueId = randomUUID(); + const typedOperationIssueId = randomUUID(); + const legacyContentMachineOperationIssueId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -786,12 +828,36 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { originKind: "plugin:paperclip.missions:operation", originId: "mission-alpha:operation-1", }, + { + id: typedOperationIssueId, + companyId, + projectId, + title: "Typed plugin operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclip.missions:operation:evaluation", + originId: "mission-alpha:operation-2", + }, + { + id: legacyContentMachineOperationIssueId, + companyId, + projectId, + title: "Legacy Content Machine operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclipai.content-machine:evaluation", + originId: "content-machine-operation-1", + }, ]); const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id); expect(defaultIssueIds).toContain(normalIssueId); expect(defaultIssueIds).toContain(pluginVisibleIssueId); expect(defaultIssueIds).not.toContain(operationIssueId); + expect(defaultIssueIds).not.toContain(typedOperationIssueId); + expect(defaultIssueIds).not.toContain(legacyContentMachineOperationIssueId); const inboxIssueIds = (await svc.list(companyId, { assigneeAgentId: agentId, @@ -800,17 +866,28 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { })).map((issue) => issue.id); expect(inboxIssueIds).toContain(normalIssueId); expect(inboxIssueIds).not.toContain(operationIssueId); + expect(inboxIssueIds).not.toContain(typedOperationIssueId); + expect(inboxIssueIds).not.toContain(legacyContentMachineOperationIssueId); await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" })) .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); + await expect(svc.list(companyId, { originKindPrefix: "plugin:paperclip.missions:operation" })) + .resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: operationIssueId }), + expect.objectContaining({ id: typedOperationIssueId }), + ])); const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id); expect(projectIssueIds).toContain(operationIssueId); + expect(projectIssueIds).toContain(typedOperationIssueId); + expect(projectIssueIds).toContain(legacyContentMachineOperationIssueId); const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id); expect(advancedIssueIds).toContain(operationIssueId); + expect(advancedIssueIds).toContain(typedOperationIssueId); + expect(advancedIssueIds).toContain(legacyContentMachineOperationIssueId); }); it("excludes plugin operation issues from unread inbox counts", async () => { @@ -1222,6 +1299,72 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(comments[0]?.body).toBe("Comment should be visible"); }); + it("lists user comments when a candidate attribution run log is missing", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const commentId = 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: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Comments issue with missing run log", + status: "todo", + priority: "medium", + }); + + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId, + contextSnapshot: { issueId }, + createdAt: new Date("2026-05-12T22:58:00.000Z"), + startedAt: new Date("2026-05-12T22:58:00.000Z"), + finishedAt: new Date("2026-05-12T23:14:00.000Z"), + logStore: "local_file", + logRef: "missing/run-log.ndjson", + logBytes: 128, + }); + + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "user-1", + body: "Comment should still be visible", + createdAt: new Date("2026-05-12T23:00:00.000Z"), + updatedAt: new Date("2026-05-12T23:00:00.000Z"), + }); + + const comments = await svc.listComments(issueId, { + order: "desc", + limit: 50, + }); + + expect(comments.map((comment) => comment.id)).toEqual([commentId]); + expect(comments[0]?.body).toBe("Comment should still be visible"); + expect(comments[0]?.metadata).toBeNull(); + }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { const companyId = randomUUID(); const blockerId = randomUUID(); @@ -2342,6 +2485,52 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); + it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const sourceIssueId = randomUUID(); + const recoveryIssueId = randomUUID(); + await db.insert(issues).values([ + { + id: sourceIssueId, + companyId, + title: "Source issue", + status: "blocked", + priority: "medium", + }, + { + id: recoveryIssueId, + companyId, + title: "Liveness escalation issue", + status: "in_progress", + priority: "high", + originKind: "harness_liveness_escalation", + originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`, + }, + ]); + + await svc.update(sourceIssueId, { + blockedByIssueIds: [recoveryIssueId], + }); + await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({ + blockedBy: [expect.objectContaining({ id: recoveryIssueId })], + }); + + await svc.update(recoveryIssueId, { + status: "done", + }); + + await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({ + blockedBy: [], + }); + }); + it("rejects execution when unresolved blockers remain", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); diff --git a/server/src/__tests__/multilingual-issues-routes.test.ts b/server/src/__tests__/multilingual-issues-routes.test.ts new file mode 100644 index 00000000..da9f0aa3 --- /dev/null +++ b/server/src/__tests__/multilingual-issues-routes.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres multilingual issue route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("multilingual issue routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + let app!: ReturnType; + let companyId!: string; + + const title = "验证中文任务"; + const description = [ + "请用中文回复并保留上下文。", + "日本語: 次の手順を書いてください。", + "हिन्दी: कृपया स्थिति बताएं।", + ].join("\n"); + const firstReply = [ + "结果: 中文响应保留。", + "日本語の返信も保持。", + "हिन्दी उत्तर भी सुरक्षित है।", + ].join("\n"); + const completionNote = [ + "完成: 已验证中文。", + "日本語: 完了しました。", + "हिन्दी: सत्यापन पूरा हुआ।", + ].join("\n"); + const documentBody = [ + "# QA notes", + "", + "- 中文: 可以创建、读取、搜索、评论。", + "- 日本語: ドキュメント本文を保持します。", + "- हिन्दी: दस्तावेज़ पाठ सुरक्षित रहता है।", + ].join("\n"); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-multilingual-issues-"); + db = createDb(tempDb.connectionString); + companyId = randomUUID(); + app = createApp(companyId); + + await db.insert(companies).values({ + id: companyId, + name: "Multilingual tenant", + issuePrefix: "LNG", + requireBoardApprovalForNewAgents: false, + }); + }, 20_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createStorage(): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(async () => { + throw new Error("Unexpected storage.putFile call in multilingual issue route test"); + }), + getObject: vi.fn(async () => { + throw new Error("Unexpected storage.getObject call in multilingual issue route test"); + }), + headObject: vi.fn(async () => ({ exists: false })), + deleteObject: vi.fn(async () => undefined), + }; + } + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes(db, createStorage())); + app.use(errorHandler); + return app; + } + + it("creates an issue with multilingual title and description", async () => { + const createRes = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ + title, + description, + status: "todo", + priority: "medium", + }); + + expect(createRes.status, JSON.stringify(createRes.body)).toBe(201); + expect(createRes.body).toMatchObject({ + title, + description, + status: "todo", + priority: "medium", + identifier: "LNG-1", + }); + }); + + it("reads the multilingual title and description unchanged", async () => { + const getRes = await request(app).get("/api/issues/LNG-1"); + expect(getRes.status, JSON.stringify(getRes.body)).toBe(200); + expect(getRes.body.title).toBe(title); + expect(getRes.body.description).toBe(description); + }); + + it("finds the issue by Chinese search text", async () => { + const searchRes = await request(app).get(`/api/companies/${companyId}/issues`).query({ q: "中文" }); + expect(searchRes.status, JSON.stringify(searchRes.body)).toBe(200); + expect(searchRes.body.map((issue: { identifier: string }) => issue.identifier)).toContain("LNG-1"); + }); + + it("preserves multilingual comment bodies", async () => { + const commentRes = await request(app) + .post("/api/issues/LNG-1/comments") + .send({ body: firstReply }); + expect(commentRes.status, JSON.stringify(commentRes.body)).toBe(201); + expect(commentRes.body.body).toBe(firstReply); + }); + + it("preserves multilingual document bodies", async () => { + const documentRes = await request(app) + .put("/api/issues/LNG-1/documents/qa-notes") + .send({ + title: "Multilingual QA", + format: "markdown", + body: documentBody, + }); + expect(documentRes.status, JSON.stringify(documentRes.body)).toBe(201); + expect(documentRes.body.body).toBe(documentBody); + }); + + it("preserves multilingual completion comments", async () => { + const completeRes = await request(app) + .patch("/api/issues/LNG-1") + .send({ status: "done", comment: completionNote }); + expect(completeRes.status, JSON.stringify(completeRes.body)).toBe(200); + expect(completeRes.body.status).toBe("done"); + expect(completeRes.body.comment.body).toBe(completionNote); + }); + + it("lists multilingual comments in write order", async () => { + const commentsRes = await request(app).get("/api/issues/LNG-1/comments").query({ order: "asc" }); + expect(commentsRes.status, JSON.stringify(commentsRes.body)).toBe(200); + expect(commentsRes.body.map((comment: { body: string }) => comment.body)).toEqual([ + firstReply, + completionNote, + ]); + }); + + it("exposes multilingual issue text in heartbeat context", async () => { + const heartbeatContextRes = await request(app).get("/api/issues/LNG-1/heartbeat-context"); + expect(heartbeatContextRes.status, JSON.stringify(heartbeatContextRes.body)).toBe(200); + expect(heartbeatContextRes.body.issue.title).toBe(title); + expect(heartbeatContextRes.body.issue.description).toBe(description); + expect(heartbeatContextRes.body.commentCursor.totalComments).toBe(2); + }); +}); diff --git a/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts new file mode 100644 index 00000000..2d536591 --- /dev/null +++ b/server/src/__tests__/permissions-upgrade-boundary-routes.test.ts @@ -0,0 +1,348 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + assets, + companies, + companyMemberships, + createDb, + documents, + heartbeatRuns, + issueAttachments, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +vi.hoisted(() => { + process.env.PAPERCLIP_HOME = "/tmp/paperclip-test-home"; + process.env.PAPERCLIP_INSTANCE_ID = "vitest"; + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; +}); + +vi.mock("../services/issue-assignment-wakeup.js", () => ({ + queueIssueAssignmentWakeup: vi.fn(), +})); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +type Db = ReturnType; + +function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId, + runId: null, + source: "agent_jwt", + }; +} + +async function createApp(db: Db, actor: Express.Request["actor"]) { + process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const [{ activityRoutes }, { issueRoutes }] = await Promise.all([ + import("../routes/activity.js"), + import("../routes/issues.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use("/api", activityRoutes(db)); + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" }); + }); + return app; +} + +async function seedCompany(db: Db, label: string) { + return db + .insert(companies) + .values({ + name: `Permissions Boundary ${label}`, + issuePrefix: `PB${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function seedAgent( + db: Db, + companyId: string, + input: { role?: string; permissions?: Record; status?: "active" | "idle" } = {}, +) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: input.role ?? "engineer", + status: input.status ?? "active", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: input.permissions ?? {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +describeEmbeddedPostgres("permissions upgrade visibility and route boundaries", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-permissions-boundary-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueAttachments); + await db.delete(assets); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueWorkProducts); + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("keeps V1 private agent visibility from becoming issue, comment, document, attachment, activity, or work product privacy", async () => { + const company = await seedCompany(db, "Visibility"); + const readerAgent = await seedAgent(db, company.id); + const privateTargetAgent = await seedAgent(db, company.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { + mode: "private", + hiddenFromDefaultDirectory: true, + }, + assignmentPolicy: { mode: "protected" }, + protectedAgent: { requiresApproval: false }, + managedBy: "permissions-extension", + }, + }, + }); + const issue = await db + .insert(issues) + .values({ + companyId: company.id, + identifier: `${company.issuePrefix}-1`, + title: "Visible work for a private target agent", + status: "todo", + priority: "medium", + assigneeAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + const comment = await db + .insert(issueComments) + .values({ + companyId: company.id, + issueId: issue.id, + authorAgentId: privateTargetAgent.id, + body: "Private target agent status is still company-visible.", + }) + .returning() + .then((rows) => rows[0]!); + const doc = await db + .insert(documents) + .values({ + companyId: company.id, + title: "Plan", + latestBody: "Shared plan body", + createdByAgentId: privateTargetAgent.id, + updatedByAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(issueDocuments).values({ + companyId: company.id, + issueId: issue.id, + documentId: doc.id, + key: "plan", + }); + const asset = await db + .insert(assets) + .values({ + companyId: company.id, + provider: "local_disk", + objectKey: `attachments/${randomUUID()}.txt`, + contentType: "text/plain", + byteSize: 12, + sha256: "abc123", + originalFilename: "note.txt", + createdByAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(issueAttachments).values({ + companyId: company.id, + issueId: issue.id, + issueCommentId: comment.id, + assetId: asset.id, + }); + await db.insert(issueWorkProducts).values({ + companyId: company.id, + issueId: issue.id, + type: "url", + provider: "test", + title: "Preview", + url: "https://example.test/preview", + status: "ready", + }); + await db.insert(activityLog).values({ + companyId: company.id, + actorType: "agent", + actorId: privateTargetAgent.id, + agentId: privateTargetAgent.id, + action: "issue.updated", + entityType: "issue", + entityId: issue.id, + details: { source: "test" }, + }); + + const app = await createApp(db, agentActor(company.id, readerAgent.id)); + + const [issueList, comments, docs, docDetail, attachments, activity, workProducts] = await Promise.all([ + request(app).get(`/api/companies/${company.id}/issues`), + request(app).get(`/api/issues/${issue.id}/comments`), + request(app).get(`/api/issues/${issue.id}/documents`), + request(app).get(`/api/issues/${issue.id}/documents/plan`), + request(app).get(`/api/issues/${issue.id}/attachments`), + request(app).get(`/api/issues/${issue.id}/activity`), + request(app).get(`/api/issues/${issue.id}/work-products`), + ]); + + expect(issueList.status, JSON.stringify(issueList.body)).toBe(200); + expect(issueList.body.items ?? issueList.body).toEqual( + expect.arrayContaining([expect.objectContaining({ id: issue.id })]), + ); + expect(comments.status, JSON.stringify(comments.body)).toBe(200); + expect(comments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: comment.id })])); + expect(docs.status, JSON.stringify(docs.body)).toBe(200); + expect(docs.body).toEqual(expect.arrayContaining([expect.objectContaining({ key: "plan" })])); + expect(docDetail.status, JSON.stringify(docDetail.body)).toBe(200); + expect(docDetail.body.body ?? docDetail.body.latestBody).toContain("Shared plan body"); + expect(attachments.status, JSON.stringify(attachments.body)).toBe(200); + expect(attachments.body).toEqual(expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })])); + expect(activity.status, JSON.stringify(activity.body)).toBe(200); + expect(activity.body).toEqual(expect.arrayContaining([expect.objectContaining({ action: "issue.updated" })])); + expect(workProducts.status, JSON.stringify(workProducts.body)).toBe(200); + expect(workProducts.body).toEqual(expect.arrayContaining([expect.objectContaining({ title: "Preview" })])); + }); + + it("denies cross-company issue reads before private-agent grant evaluation can matter", async () => { + const sourceCompany = await seedCompany(db, "Source"); + const targetCompany = await seedCompany(db, "Target"); + const sourceAgent = await seedAgent(db, sourceCompany.id); + const privateTargetAgent = await seedAgent(db, targetCompany.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, + assignmentPolicy: { mode: "company_default" }, + protectedAgent: { requiresApproval: false }, + }, + }, + }); + const issue = await db + .insert(issues) + .values({ + companyId: targetCompany.id, + title: "Other company work", + status: "todo", + priority: "medium", + assigneeAgentId: privateTargetAgent.id, + }) + .returning() + .then((rows) => rows[0]!); + + const res = await request(await createApp(db, agentActor(sourceCompany.id, sourceAgent.id))) + .get(`/api/issues/${issue.id}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Agent key cannot access another company"); + }); + + it("allows same-company route assignment after upgrade but keeps private target assignment grant constrained", async () => { + const company = await seedCompany(db, "Assignment"); + const actorAgent = await seedAgent(db, company.id); + const openTargetAgent = await seedAgent(db, company.id); + const privateTargetAgent = await seedAgent(db, company.id, { + permissions: { + authorizationPolicy: { + agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true }, + assignmentPolicy: { mode: "company_default" }, + protectedAgent: { requiresApproval: false }, + managedBy: "permissions-extension", + }, + }, + }); + const app = await createApp(db, agentActor(company.id, actorAgent.id)); + + const openAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Assignable after upgrade", assigneeAgentId: openTargetAgent.id }); + expect(openAssignment.status, JSON.stringify(openAssignment.body)).toBe(201); + + const deniedPrivateAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Private target needs scope", assigneeAgentId: privateTargetAgent.id }); + expect(deniedPrivateAssignment.status).toBe(403); + expect(deniedPrivateAssignment.body.error).toContain("private"); + + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "tasks:assign_scope", + scope: { assigneeAgentIds: [privateTargetAgent.id] }, + grantedByUserId: null, + }); + + const allowedPrivateAssignment = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Private target has explicit scope", assigneeAgentId: privateTargetAgent.id }); + expect(allowedPrivateAssignment.status, JSON.stringify(allowedPrivateAssignment.body)).toBe(201); + + const otherPrivateTargetAgent = await seedAgent(db, company.id, { + permissions: privateTargetAgent.permissions as Record, + }); + const deniedOutsideScope = await request(app) + .post(`/api/companies/${company.id}/issues`) + .send({ title: "Different private target stays denied", assigneeAgentId: otherPrivateTargetAgent.id }); + expect(deniedOutsideScope.status).toBe(403); + expect(deniedOutsideScope.body.error).toContain("private"); + }); +}); diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts new file mode 100644 index 00000000..87737816 --- /dev/null +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -0,0 +1,322 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + createDb, + invites, + principalPermissionGrants, +} from "@paperclipai/db"; +import { buildHostServices } from "../services/plugin-host-services.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const pluginId = "plugin-record-id"; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: vi.fn(), + subscribe: vi.fn(), + clear: vi.fn(), + }; + }, + } as any; +} + +async function createCompany(db: ReturnType, prefix: string) { + return db + .insert(companies) + .values({ + name: `${prefix} ${randomUUID()}`, + issuePrefix: `${prefix}${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +describeEmbeddedPostgres("plugin access and authorization host services", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-access-authz-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(invites); + await db.delete(agents); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("rejects grant writes for principals outside the requested company", async () => { + const targetCompany = await createCompany(db, "PAX"); + const otherCompany = await createCompany(db, "PAY"); + const otherAgent = await db + .insert(agents) + .values({ + companyId: otherCompany.id, + name: "Other agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }) + .returning() + .then((rows) => rows[0]!); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + await expect( + services.authorization.setGrants({ + companyId: targetCompany.id, + principalType: "agent", + principalId: otherAgent.id, + grants: [{ permissionKey: "tasks:assign" }], + }), + ).rejects.toThrow("Agent not found"); + + const rows = await db.select().from(principalPermissionGrants); + expect(rows).toEqual([]); + services.dispose(); + }); + + it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => { + const company = await createCompany(db, "PAZ"); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + const created = await services.access.createInvite({ + companyId: company.id, + allowedJoinTypes: "human", + defaultsPayload: { + human: { role: "operator", apiKey: "secret-value" }, + secret: "top-secret", + }, + }); + + expect(created.token).toMatch(/^pcp_invite_/); + expect("tokenHash" in created).toBe(false); + expect(created.defaultsPayload).toMatchObject({ + human: { role: "operator", apiKey: "***REDACTED***" }, + secret: "***REDACTED***", + }); + + const listed = await services.access.listInvites({ companyId: company.id }); + expect(listed.invites).toHaveLength(1); + expect("token" in listed.invites[0]!).toBe(false); + expect("tokenHash" in listed.invites[0]!).toBe(false); + services.dispose(); + }); + + it("filters authorization audit entries by allow or deny decision details", async () => { + const company = await createCompany(db, "PAU"); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-1", + details: { decision: "allow", secret: "do-not-leak" }, + createdAt: new Date("2026-01-02T00:00:00Z"), + }, + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-2", + details: { reason: "deny_scope" }, + createdAt: new Date("2026-01-03T00:00:00Z"), + }, + ]); + + const [allowed, denied] = await Promise.all([ + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "allow", + limit: 1, + }), + services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "deny", + }), + ]); + + expect(allowed).toHaveLength(1); + expect(allowed[0]!.entityId).toBe("issue-1"); + expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" }); + expect(denied).toHaveLength(1); + expect(denied[0]!.entityId).toBe("issue-2"); + services.dispose(); + }); + + it("uses persisted agent policy for plugin assignment preview and explanation", async () => { + const company = await createCompany(db, "PAP"); + const [actorAgent, targetAgent] = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: "Actor agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + { + companyId: company.id, + name: "Protected target", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }, + ]) + .returning(); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent!.id, + status: "active", + membershipRole: "member", + }); + + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + const updatedPolicy = await services.authorization.updatePolicy({ + companyId: company.id, + resourceType: "agent", + resourceId: targetAgent!.id, + policy: { + assignmentPolicy: { + mode: "protected", + protectedAgentRequiresApproval: true, + }, + protectedAgent: { + requiresApproval: true, + approvalReason: "Needs board approval", + }, + managedBy: "permissions-extension", + }, + }); + const input = { + companyId: company.id, + actor: { + type: "agent" as const, + agentId: actorAgent!.id, + companyId: company.id, + source: "agent_key" as const, + }, + target: { assigneeAgentId: targetAgent!.id }, + }; + const [policy, preview, explanation] = await Promise.all([ + Promise.resolve(updatedPolicy), + services.authorization.previewAssignment(input), + services.authorization.explainAssignment(input), + ]); + + expect(policy.policy).toMatchObject({ + protectedAgent: { requiresApproval: true }, + }); + expect(preview).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + expect(explanation).toMatchObject(preview); + + const injectedBoardPreview = await services.authorization.previewAssignment({ + companyId: company.id, + actor: { + type: "board", + userId: "operator", + companyIds: [company.id], + source: "local_implicit", + isInstanceAdmin: true, + } as any, + target: { assigneeAgentId: targetAgent!.id }, + }); + expect(injectedBoardPreview).toMatchObject({ + allowed: false, + reason: "deny_policy_restricted", + }); + services.dispose(); + }); + + it("sanitizes plugin authorization policy updates and records audit activity", async () => { + const company = await createCompany(db, "PAS"); + const targetAgent = await db + .insert(agents) + .values({ + companyId: company.id, + name: "Policy target", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + }) + .returning() + .then((rows) => rows[0]!); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); + + const updatedPolicy = await services.authorization.updatePolicy({ + companyId: company.id, + resourceType: "agent", + resourceId: targetAgent.id, + policy: { + assignmentPolicy: { mode: "protected" }, + apiKey: "sk-test-secret", + nested: { + authorization: "Bearer should-not-persist", + safeLabel: "kept", + }, + }, + }); + + expect(updatedPolicy.policy).toMatchObject({ + assignmentPolicy: { mode: "protected" }, + apiKey: "***REDACTED***", + nested: { + authorization: "***REDACTED***", + safeLabel: "kept", + }, + }); + + const rows = await db.select().from(activityLog); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + companyId: company.id, + actorType: "plugin", + actorId: pluginId, + action: "authorization.policy_updated_by_plugin", + entityType: "agent", + entityId: targetAgent.id, + }); + expect(rows[0]!.details).toMatchObject({ + hasPolicy: true, + sourcePluginId: pluginId, + sourcePluginKey: "permissions-extension", + }); + expect(JSON.stringify(rows[0]!.details)).not.toContain("sk-test-secret"); + expect(JSON.stringify(rows[0]!.details)).not.toContain("should-not-persist"); + services.dispose(); + }); +}); diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 5ae677e5..d69fd34a 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -30,6 +30,7 @@ import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const multiMigrationPluginKey = "paperclip.dbfixture"; +const llmWikiPluginKey = "paperclipai.plugin-llm-wiki"; if (!embeddedPostgresSupport.supported) { console.warn( @@ -48,6 +49,63 @@ describe("plugin database SQL validation", () => { ).not.toThrow(); }); + it("allows qualified index creation and namespace-scoped migration backfills", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE INDEX IF NOT EXISTS rows_issue_idx ON plugin_test.rows (issue_id)", + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginMigrationStatement( + ` + WITH source_rows AS ( + SELECT id FROM plugin_test.rows + ) + INSERT INTO plugin_test.row_copies (id) + SELECT id FROM source_rows + ON CONFLICT (id) DO NOTHING + `, + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginMigrationStatement( + ` + UPDATE plugin_test.rows r + SET copied_from_id = s.id + FROM plugin_test.source_rows s + WHERE s.id = r.id + `, + "plugin_test", + ) + ).not.toThrow(); + }); + + it("keeps migration backfill writes scoped to the plugin namespace", () => { + expect(() => + validatePluginMigrationStatement( + "CREATE TABLE rows (id uuid PRIMARY KEY, issue_id uuid REFERENCES public.issues(id))", + "plugin_test", + ["issues"], + ) + ).toThrow(/fully qualified/i); + expect(() => + validatePluginMigrationStatement( + "WITH source_rows AS (SELECT id FROM plugin_test.rows) INSERT INTO public.issues (id) SELECT id FROM source_rows", + "plugin_test", + ["issues"], + ) + ).toThrow(/public/i); + expect(() => + validatePluginMigrationStatement( + "UPDATE public.issues SET title = 'bad'", + "plugin_test", + ["issues"], + ) + ).toThrow(/public/i); + }); + it("rejects migrations that create public objects", () => { expect(() => validatePluginMigrationStatement( @@ -137,10 +195,11 @@ describeEmbeddedPostgres("plugin database namespaces", () => { }, 20_000); afterEach(async () => { - for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) { + for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey, llmWikiPluginKey]) { const namespace = derivePluginDatabaseNamespace(pluginKey); await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`)); } + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${derivePluginDatabaseNamespace(llmWikiPluginKey, "llm_wiki")}" CASCADE`)); await db.delete(pluginMigrations); await db.delete(pluginDatabaseNamespaces); await db.delete(plugins); @@ -164,6 +223,29 @@ describeEmbeddedPostgres("plugin database namespaces", () => { return packageRoot; } + function llmWikiManifest(): PaperclipPluginManifestV1 { + return { + id: llmWikiPluginKey, + apiVersion: 1, + version: "0.1.0", + displayName: "LLM Wiki", + description: "Local-file LLM Wiki plugin.", + author: "Paperclip", + categories: ["automation", "ui"], + capabilities: [ + "database.namespace.migrate", + "database.namespace.read", + "database.namespace.write", + ], + entrypoints: { worker: "./dist/worker.js" }, + database: { + namespaceSlug: "llm_wiki", + migrationsDir: "migrations", + coreReadTables: ["companies", "issues", "projects", "agents"], + }, + }; + } + async function createInstallablePluginPackage( pluginManifest: PaperclipPluginManifestV1, migrationSql: string, @@ -252,6 +334,61 @@ describeEmbeddedPostgres("plugin database namespaces", () => { expect(migrations).toHaveLength(2); }); + it("applies the bundled LLM Wiki migrations through the production validator", async () => { + const pluginManifest = llmWikiManifest(); + const repoRoot = path.basename(process.cwd()) === "server" ? path.resolve(process.cwd(), "..") : process.cwd(); + const packageRoot = path.join(repoRoot, "packages", "plugins", "plugin-llm-wiki"); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id, pluginManifest.database?.namespaceSlug); + const pluginId = await installPluginRecord(pluginManifest); + + await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot); + + const migrations = await db + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied"))); + expect(migrations.map((migration) => migration.migrationKey)).toEqual([ + "001_llm_wiki.sql", + "002_paperclip_distillation.sql", + "003_spaces.sql", + ]); + + const constraintRows = Array.from( + await db.execute( + sql<{ table_name: string; conname: string; columns: string[] }>` + SELECT t.relname AS table_name, c.conname, array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[] AS columns + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum + WHERE c.connamespace = ${namespace}::regnamespace AND c.contype = 'u' + GROUP BY t.relname, c.conname + ORDER BY t.relname, c.conname + `, + ) as Iterable<{ table_name: string; conname: string; columns: string[] }>, + ); + const constraints = constraintRows.map((row) => row.conname); + const uniqueColumnSets = new Set( + constraintRows.map((row) => `${row.table_name}:${row.columns.join(",")}`), + ); + expect(constraints).toEqual( + expect.arrayContaining([ + "wiki_pages_company_wiki_space_path_key", + "distillation_cursors_company_wiki_space_scope_key", + "distillation_work_items_company_wiki_space_idempotency_key", + "page_bindings_company_wiki_space_page_path_key", + ]), + ); + expect(constraints).not.toContain("wiki_pages_company_id_wiki_id_path_key"); + expect(constraints).not.toContain("paperclip_distillation_cursor_company_id_wiki_id_source_sco_key"); + expect(constraints).not.toContain("paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key"); + expect(constraints).not.toContain("paperclip_page_bindings_company_id_wiki_id_page_path_key"); + expect(uniqueColumnSets).not.toContain("wiki_pages:company_id,wiki_id,path"); + expect(uniqueColumnSets).not.toContain("paperclip_distillation_cursors:company_id,wiki_id,source_scope,scope_key,source_kind"); + expect(uniqueColumnSets).not.toContain("paperclip_distillation_work_items:company_id,wiki_id,idempotency_key"); + expect(uniqueColumnSets).not.toContain("paperclip_page_bindings:company_id,wiki_id,page_path"); + }); + it("applies migrations once and allows whitelisted core joins at runtime", async () => { const pluginManifest = manifest(); const namespace = derivePluginDatabaseNamespace(pluginManifest.id); diff --git a/server/src/__tests__/plugin-execution-workspace-bridge.test.ts b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts new file mode 100644 index 00000000..6c672f51 --- /dev/null +++ b/server/src/__tests__/plugin-execution-workspace-bridge.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js"; +import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js"; + +describe("plugin execution workspace bridge", () => { + it("routes metadata reads through the host client when the capability is declared", async () => { + const get = vi.fn().mockResolvedValue({ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + path: "/tmp/workspace-1", + cwd: "/tmp/workspace-1", + repoUrl: null, + baseRef: "main", + branchName: "feature/workspace-1", + providerType: "git_worktree", + providerMetadata: null, + }); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: ["execution.workspaces.read"], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/workspace-1", + }); + expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" }); + }); + + it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => { + const get = vi.fn(); + const handlers = createHostClientHandlers({ + pluginId: "workspace-plugin", + capabilities: [], + services: { + executionWorkspaces: { get }, + } as any, + }); + + await expect( + handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }), + ).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED, + }); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/plugin-lifecycle-restart.test.ts b/server/src/__tests__/plugin-lifecycle-restart.test.ts new file mode 100644 index 00000000..04026e6e --- /dev/null +++ b/server/src/__tests__/plugin-lifecycle-restart.test.ts @@ -0,0 +1,129 @@ +/** + * Regression test for PAP-9585. + * + * `restartWorker` is called by the dev file-watcher whenever a local-path + * plugin's source files change. Before PAP-9585 it only bounced the worker + * subprocess, which left newly added `migrations/*.sql` files unapplied — the + * plugin schema would silently drift out of sync with worker code. + * + * The fix is for `restartWorker` to do a full deactivate + reactivate cycle + * via the plugin loader, which re-reads the manifest from disk and runs + * `applyMigrations` (idempotently) before starting the new worker. + */ +import { describe, expect, it, vi } from "vitest"; + +const pluginRecord = { + id: "plugin-1", + pluginKey: "example.plugin", + status: "ready", + manifestJson: { id: "example.plugin", capabilities: [] }, + packageName: "@example/plugin", + version: "1.0.0", + packagePath: "/tmp/example-plugin", +}; + +const mockRegistry = vi.hoisted(() => ({ + getById: vi.fn(), + getByKey: vi.fn(), + update: vi.fn(), + updateStatus: vi.fn(), + upsertConfig: vi.fn(), + getConfig: vi.fn(), + list: vi.fn(), + delete: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => mockRegistry, +})); + +import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; +import type { PluginLoader } from "../services/plugin-loader.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; + +function makeWorkerManagerStub() { + const handle = { + restart: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + return { + handle, + workerManager: { + getWorker: vi.fn().mockReturnValue(handle), + isRunning: vi.fn().mockReturnValue(true), + startWorker: vi.fn().mockResolvedValue(undefined), + stopWorker: vi.fn().mockResolvedValue(undefined), + restartWorker: vi.fn().mockResolvedValue(undefined), + } as unknown as PluginWorkerManager, + }; +} + +describe("pluginLifecycleManager.restartWorker", () => { + it("does a full deactivate+reactivate cycle when the loader has runtime services", async () => { + mockRegistry.getById.mockResolvedValue(pluginRecord); + mockRegistry.updateStatus.mockResolvedValue(pluginRecord); + + const { handle, workerManager } = makeWorkerManagerStub(); + + const loader: Partial = { + hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"], + loadSingle: vi.fn().mockResolvedValue({ + success: true, + plugin: pluginRecord, + registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }) as PluginLoader["loadSingle"], + unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"], + }; + + const lifecycle = pluginLifecycleManager( + {} as never, + { loader: loader as PluginLoader, workerManager }, + ); + const stopped = vi.fn(); + const started = vi.fn(); + lifecycle.on("plugin.worker_stopped", stopped); + lifecycle.on("plugin.worker_started", started); + + await lifecycle.restartWorker("plugin-1"); + + expect(loader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin"); + expect(loader.loadSingle).toHaveBeenCalledWith("plugin-1"); + // The bare worker handle should NOT be bounced — the loader handles + // worker (re)start as part of activate. + expect(handle.restart).not.toHaveBeenCalled(); + expect(stopped).not.toHaveBeenCalled(); + expect(started).not.toHaveBeenCalled(); + }); + + it("falls back to bouncing the worker handle when the loader has no runtime services", async () => { + mockRegistry.getById.mockResolvedValue(pluginRecord); + mockRegistry.updateStatus.mockResolvedValue(pluginRecord); + + const { handle, workerManager } = makeWorkerManagerStub(); + + const loader: Partial = { + hasRuntimeServices: vi.fn().mockReturnValue(false) as PluginLoader["hasRuntimeServices"], + loadSingle: vi.fn() as PluginLoader["loadSingle"], + unloadSingle: vi.fn() as PluginLoader["unloadSingle"], + }; + + const lifecycle = pluginLifecycleManager( + {} as never, + { loader: loader as PluginLoader, workerManager }, + ); + const stopped = vi.fn(); + const started = vi.fn(); + lifecycle.on("plugin.worker_stopped", stopped); + lifecycle.on("plugin.worker_started", started); + + await lifecycle.restartWorker("plugin-1"); + + expect(loader.unloadSingle).not.toHaveBeenCalled(); + expect(loader.loadSingle).not.toHaveBeenCalled(); + expect(handle.restart).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledTimes(1); + expect(stopped).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" }); + expect(started).toHaveBeenCalledTimes(1); + expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" }); + }); +}); diff --git a/server/src/__tests__/plugin-local-folders.test.ts b/server/src/__tests__/plugin-local-folders.test.ts index 073220c4..a1c354d9 100644 --- a/server/src/__tests__/plugin-local-folders.test.ts +++ b/server/src/__tests__/plugin-local-folders.test.ts @@ -219,6 +219,14 @@ describe("plugin local folders", () => { expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]); }); + it("creates missing nested parent directories for atomic writes", async () => { + const root = await makeRoot(); + + await writePluginLocalFolderTextAtomic(root, "cases/active/smoke/README.md", "hello"); + + await expect(readPluginLocalFolderText(root, "cases/active/smoke/README.md")).resolves.toBe("hello"); + }); + it("returns the real folder key after deleting a file", async () => { const root = await makeRoot(); await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8"); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index 402419c6..de642f30 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -11,6 +11,7 @@ import { companies, costEvents, createDb, + executionWorkspaces, heartbeatRuns, issueRelations, issues, @@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { await db.delete(agentWakeupRequests); await db.delete(issueRelations); await db.delete(issues); + await db.delete(executionWorkspaces); await db.delete(pluginManagedResources); await db.delete(projects); await db.delete(plugins); @@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { return root; } + it("returns plugin-safe execution workspace metadata scoped to the company", async () => { + const { companyId } = await seedCompanyAndAgent(); + const otherCompanyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other", + issuePrefix: issuePrefix(otherCompanyId), + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerRef: "/tmp/paperclip-feature", + metadata: { + providerMetadata: { sandboxId: "sandbox-1" }, + workspaceRealizationRequest: { hiddenInternal: true }, + }, + }); + + const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub()); + + await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({ + id: workspaceId, + companyId, + projectId, + projectWorkspaceId: null, + path: "/tmp/paperclip-feature", + cwd: "/tmp/paperclip-feature", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/workspace", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull(); + }); + it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const blockerIssueId = randomUUID(); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 963d3cd1..272e20c3 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -42,6 +42,7 @@ async function createApp( jobDeps?: unknown; toolDeps?: unknown; bridgeDeps?: unknown; + captureJsonContext?: (context: unknown, body: unknown) => void; } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ @@ -56,6 +57,16 @@ async function createApp( const app = express(); app.use(express.json()); + if (routeOverrides.captureJsonContext) { + app.use((_req, res, next) => { + const originalJson = res.json.bind(res); + res.json = ((body: unknown) => { + routeOverrides.captureJsonContext?.((res as any).__errorContext, body); + return originalJson(body); + }) as typeof res.json; + next(); + }); + } app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); @@ -103,6 +114,17 @@ function boardActor(overrides: Record = {}) { }; } +function agentActor(overrides: Record = {}) { + return { + type: "agent", + agentId: agentA, + companyId: companyA, + runId: runA, + source: "agent_jwt", + ...overrides, + }; +} + function readyPlugin() { mockRegistry.getById.mockResolvedValue({ id: pluginId, @@ -602,6 +624,28 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).not.toHaveBeenCalled(); }); + it("forwards authorized bridge company scope to the plugin worker", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/health`) + .send({ companyId: companyA, params: { view: "compact" } }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "getData", { + key: "health", + companyId: companyA, + params: { view: "compact" }, + renderEnvironment: null, + }); + }); + it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => { readyPlugin(); const call = vi.fn().mockResolvedValue({ ok: true }); @@ -623,10 +667,194 @@ describe.sequential("plugin tool and bridge authz", () => { expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", params: {}, + actorContext: { + type: "user", + userId: "admin-1", + agentId: null, + runId: null, + companyId: null, + }, renderEnvironment: null, }); }); + it("passes authenticated actor context and overrides spoofed company scope for plugin actions", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ runId: runA }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerUserId: "spoofed-user", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerUserId: "spoofed-user", + }, + actorContext: { + type: "user", + userId: "user-1", + agentId: null, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("uses null for board actor userId when no authenticated user id is present", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(boardActor({ userId: undefined }), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyA }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", expect.objectContaining({ + actorContext: expect.objectContaining({ + type: "user", + userId: null, + companyId: companyA, + }), + })); + }); + + it("allows agent-scoped plugin actions with authenticated actor context", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(res.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + + call.mockClear(); + const legacyRes = await request(app) + .post(`/api/plugins/${pluginId}/bridge/action`) + .send({ + key: "sync", + companyId: companyA, + params: { + companyId: companyB, + reviewerAgentId: "spoofed-agent", + }, + }); + + expect(legacyRes.status).toBe(200); + expect(call).toHaveBeenCalledWith(pluginId, "performAction", { + key: "sync", + params: { + companyId: companyA, + reviewerAgentId: "spoofed-agent", + }, + actorContext: { + type: "agent", + userId: null, + agentId: agentA, + runId: runA, + companyId: companyA, + }, + renderEnvironment: null, + }); + }); + + it("rejects agent plugin actions outside the authenticated company scope", async () => { + readyPlugin(); + const call = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(agentActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/actions/sync`) + .send({ companyId: companyB }); + + expect(res.status).toBe(403); + expect(call).not.toHaveBeenCalled(); + }); + + it("attaches worker bridge errors to the HTTP logger context", async () => { + readyPlugin(); + const call = vi.fn().mockRejectedValue(new Error("missing source_objects column")); + const captured: Array<{ context: any; body: unknown }> = []; + const { app } = await createApp(boardActor(), {}, { + bridgeDeps: { + workerManager: { call }, + }, + captureJsonContext: (context, body) => { + captured.push({ context, body }); + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/data/source-objects`) + .send({ companyId: companyA }); + + expect(res.status).toBe(502); + expect(res.body).toMatchObject({ + code: "UNKNOWN", + message: "missing source_objects column", + }); + expect(captured.at(-1)?.context?.error).toMatchObject({ + message: "missing source_objects column", + details: { + pluginId, + pluginKey: "paperclip.example", + bridgeMethod: "getData", + dataKey: "source-objects", + bridgeCode: "UNKNOWN", + }, + }); + }); + it("rejects manual job triggers for non-admin board users", async () => { const scheduler = { triggerJob: vi.fn() }; const jobStore = { getJobByIdForPlugin: vi.fn() }; diff --git a/server/src/__tests__/plugin-sdk-testing.test.ts b/server/src/__tests__/plugin-sdk-testing.test.ts index b3efa7e1..bb66dae7 100644 --- a/server/src/__tests__/plugin-sdk-testing.test.ts +++ b/server/src/__tests__/plugin-sdk-testing.test.ts @@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; describe("plugin SDK test harness", () => { + it("returns scoped execution workspace metadata with the read capability", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-execution-workspace-metadata", + apiVersion: 1, + version: "0.1.0", + displayName: "Execution Workspace Metadata", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["execution.workspaces.read"], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + harness.seed({ + executionWorkspaces: [{ + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + path: "/tmp/paperclip-test", + cwd: "/tmp/paperclip-test", + repoUrl: "https://example.com/repo.git", + baseRef: "main", + branchName: "feature/test", + providerType: "git_worktree", + providerMetadata: { sandboxId: "sandbox-1" }, + }], + }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({ + id: "workspace-1", + cwd: "/tmp/paperclip-test", + branchName: "feature/test", + providerMetadata: { sandboxId: "sandbox-1" }, + }); + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull(); + }); + + it("requires execution.workspaces.read before returning workspace metadata", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-execution-workspace-read", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Workspace Read Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow( + "missing required capability 'execution.workspaces.read'", + ); + }); + it("requires skills.managed capability before resetting a missing declaration", async () => { const manifest: PaperclipPluginManifestV1 = { id: "paperclip.test-missing-managed-skill-capability", @@ -25,4 +82,29 @@ describe("plugin SDK test harness", () => { "missing required capability 'skills.managed'", ); }); + + it("requires access and authorization capabilities for permission SDK calls", async () => { + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.test-missing-access-authz-capability", + apiVersion: 1, + version: "0.1.0", + displayName: "Missing Access Capability", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "./dist/worker.js" }, + }; + const harness = createTestHarness({ manifest }); + + await expect(harness.ctx.access.members.list({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'access.members.read'", + ); + await expect(harness.ctx.authorization.grants.list({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'authorization.grants.read'", + ); + await expect(harness.ctx.authorization.audit.search({ companyId: "company-1" })).rejects.toThrow( + "missing required capability 'authorization.audit.read'", + ); + }); }); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index 4f578fda..8d2d7194 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -3,7 +3,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; import { + createHostClientHandlers, JsonRpcCallError, + PLUGIN_RPC_ERROR_CODES, + type HostServices, type HostToWorkerMethods, } from "@paperclipai/plugin-sdk"; import { @@ -14,6 +17,10 @@ import { const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs"); +const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join( + FIXTURES_DIR, + "plugin-worker-invocation-scope.cjs", +); const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs"); const TEST_MANIFEST: PaperclipPluginManifestV1 = { @@ -178,4 +185,236 @@ describe("plugin-worker-manager stderr failure context", () => { await handle.stop().catch(() => undefined); } }); + + it("passes performAction invocation scope to nested worker host calls", async () => { + const companiesGet = vi.fn(async ( + params: { companyId: string }, + context?: { invocationScope?: { companyId?: string | null } | null }, + ) => ({ + id: params.companyId, + scopedCompanyId: context?.invocationScope?.companyId ?? null, + })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet as never, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "echo", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).resolves.toEqual({ + id: "company-a", + scopedCompanyId: "company-a", + }); + expect(companiesGet).toHaveBeenCalledWith( + { companyId: "company-a" }, + { invocationScope: { companyId: "company-a" } }, + ); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("passes echoed invocation scope to worker-to-host handlers", async () => { + const companiesGet = vi.fn(async () => ({ id: "company-1" })); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: { + "companies.get": companiesGet, + }, + }); + + try { + await handle.start(); + + await expect(handle.call("getData", { + key: "probe", + companyId: "company-1", + params: { + mode: "echo", + requestedCompanyId: "company-1", + }, + } as HostToWorkerMethods["getData"][0])).resolves.toEqual({ id: "company-1" }); + + expect(companiesGet).toHaveBeenCalledWith( + { companyId: "company-1" }, + { invocationScope: { companyId: "company-1" } }, + ); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects performAction nested host calls that omit the invocation id", async () => { + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + list: vi.fn(async () => []), + get: vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })), + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: handlers, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + requestedCompanyId: "company-b", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("unknown invocation scope"), + }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects nested worker host calls that forge an unknown invocation id", async () => { + const companiesGet = vi.fn(async (params: { companyId: string }) => ({ id: params.companyId })); + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + get: companiesGet, + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: handlers, + }); + + try { + await handle.start(); + + await expect(handle.call("performAction", { + key: "probe", + params: { + mode: "unknown", + requestedCompanyId: "company-a", + }, + actorContext: { + type: "agent", + userId: null, + agentId: "agent-1", + runId: "run-1", + companyId: "company-a", + }, + renderEnvironment: null, + })).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + message: expect.stringContaining("unknown invocation scope"), + }); + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("rejects missing or unknown invocation ids while a company invocation is active", async () => { + const companiesGet = vi.fn(async () => ({ id: "company-2" })); + const hostHandlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: ["companies.read"], + services: { + companies: { + get: companiesGet, + }, + } as unknown as HostServices, + }); + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers, + }); + + try { + await handle.start(); + + for (const mode of ["omit", "unknown"]) { + await expect(handle.call("getData", { + key: "probe", + companyId: "company-1", + params: { + mode, + requestedCompanyId: "company-2", + }, + } as HostToWorkerMethods["getData"][0])).rejects.toMatchObject({ + code: PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED, + }); + } + + expect(companiesGet).not.toHaveBeenCalled(); + } finally { + await handle.stop().catch(() => undefined); + } + }); }); diff --git a/server/src/__tests__/qa-routine-secrets-e2e.test.ts b/server/src/__tests__/qa-routine-secrets-e2e.test.ts new file mode 100644 index 00000000..43d7910a --- /dev/null +++ b/server/src/__tests__/qa-routine-secrets-e2e.test.ts @@ -0,0 +1,458 @@ +// QA validation for [PAP-9522](/PAP/issues/PAP-9522). Drives the routine-secret +// chain end-to-end against a real embedded Postgres: +// +// 1. Routine env reaches the heartbeat runtime via `resolveExecutionRunAdapterConfig` +// using `secretsSvc.resolveEnvBindings` with a `consumerType: "routine"` context, +// even when the executing agent has zero direct bindings for that secret. +// 2. Precedence: agent < project < routine for a shared key. +// 3. `secret_access_events` records routine consumption but NEVER the resolved value. +// 4. Restoring an older revision re-syncs `company_secret_bindings` to the snapshot env. +// 5. Legacy fallback: a routine_run with null `routine_revision_id` still resolves +// the routine's current env (matches the explicit acceptance criterion). +// 6. Disabled / missing / cross-company secret bindings fail clearly without +// echoing the value. + +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { eq, and } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companySecretBindings, + companySecrets, + companySecretVersions, + createDb, + projects, + routineRuns, + routines, + secretAccessEvents, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; +import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts"; + +const support = await getEmbeddedPostgresTestSupport(); +const describeEmbedded = support.supported ? describe : describe.skip; +if (!support.supported) { + console.warn(`Skipping QA e2e on this host: ${support.reason ?? "embedded pg unsupported"}`); +} + +describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-qa-routine-secrets-${randomUUID()}`); + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-qa-routine-secrets-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(routineRuns); + await db.delete(routines); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + async function seed() { + const companyId = randomUUID(); + const executorAgentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "QA Co", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + // Note: executor agent has NO secret bindings of its own — this is the + // whole point of routine env (the secret rides with the routine, not the agent). + await db.insert(agents).values({ + id: executorAgentId, + companyId, + name: "Executor", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: { env: {} }, + runtimeConfig: {}, + permissions: {}, + }); + return { companyId, executorAgentId }; + } + + const ROUTINE_VALUE = "super-sekret-routine-value"; + const PROJECT_VALUE = "project-overlay-value"; + const AGENT_VALUE = "agent-base-value"; + + it("resolves routine env for an executing agent that has no direct binding, with routine winning precedence and zero value in access events", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: ROUTINE_VALUE, + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "qa routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + SHARED: { type: "plain", value: "routine-overrides" }, + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Verify binding is owned by the routine, not the executing agent. + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { targetType: "routine", secretId: secret.id, configPath: "env.ROUTINE_API_KEY" }, + ]); + + // Drive the real heartbeat resolution path with the routine env. + // issueId/heartbeatRunId left null because secret_access_events has FK + // constraints on both — populating them would require seeding issue and + // heartbeat_run rows just for FK validity. The routine consumer fields are + // what this test cares about. + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { SHARED: AGENT_VALUE, AGENT_ONLY: AGENT_VALUE } }, + projectEnv: { SHARED: { type: "plain", value: PROJECT_VALUE } }, + routineEnv: routine.env, + secretsSvc: secrets, + }); + + expect(result.resolvedConfig.env).toMatchObject({ + AGENT_ONLY: AGENT_VALUE, + SHARED: "routine-overrides", // routine beats project beats agent + ROUTINE_API_KEY: ROUTINE_VALUE, + }); + expect(result.secretKeys.has("ROUTINE_API_KEY")).toBe(true); + expect(result.secretManifest.some((m) => m.envKey === "ROUTINE_API_KEY")).toBe(true); + // Manifest must not echo the resolved value. + expect(JSON.stringify(result.secretManifest)).not.toContain(ROUTINE_VALUE); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, secret.id)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + consumerType: "routine", + consumerId: routine.id, + actorType: "agent", + actorId: executorAgentId, + configPath: "env.ROUTINE_API_KEY", + outcome: "success", + }); + // No serialized field of the access event row can contain the secret value. + expect(JSON.stringify(events[0])).not.toContain(ROUTINE_VALUE); + }); + + it("rejects routine env that references a secret from a different company", async () => { + const { companyId } = await seed(); + const { companyId: otherCompanyId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const foreignSecret = await secrets.create(otherCompanyId, { + name: `foreign-${randomUUID()}`, + provider: "local_encrypted", + value: "cross-company-leak-bait", + }); + + await expect( + routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "cross company", + description: null, + assigneeAgentId: null, + priority: "medium", + status: "paused", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + BAD: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" }, + }, + }, + {}, + ), + ).rejects.toThrow(/same company/i); + }); + + it("surfaces a clear, value-free error when a routine secret is missing/deleted at resolution time", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `to-be-deleted-${randomUUID()}`, + provider: "local_encrypted", + value: "doomed-secret-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "doomed routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + DOOMED: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Hard delete the secret out from under the routine; the routine env now + // points at a vanished id. + await secrets.remove(secret.id); + + let caught: unknown = null; + try { + await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeTruthy(); + const message = String((caught as Error)?.message ?? caught); + expect(message).not.toContain("doomed-secret-value"); + }); + + it("restoring an older revision re-syncs company_secret_bindings to the snapshot env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secretA = await secrets.create(companyId, { + name: `a-${randomUUID()}`, + provider: "local_encrypted", + value: "val-a", + }); + const secretB = await secrets.create(companyId, { + name: `b-${randomUUID()}`, + provider: "local_encrypted", + value: "val-b", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "restore routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + }, + }, + {}, + ); + const rev1Id = routine.latestRevisionId!; + + await routines.update( + routine.id, + { + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + BETA: { type: "secret_ref", secretId: secretB.id, version: "latest" }, + }, + }, + {}, + ); + + let bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath).sort()).toEqual(["env.ALPHA", "env.BETA"]); + + await routines.restoreRevision(routine.id, rev1Id, {}); + + bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath)).toEqual(["env.ALPHA"]); + expect(bindings[0]?.secretId).toBe(secretA.id); + }); + + it("legacy run with null routine_revision_id falls back to the routine's current env (still resolves)", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `legacy-${randomUUID()}`, + provider: "local_encrypted", + value: "legacy-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "legacy routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + LEGACY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Simulate an old routine_run row (predating the migration) with no + // routine_revision_id. The fallback path in `getRoutineEnvForExecutionIssue` + // should still resolve to the routine's current env. Here we exercise the + // resolution layer directly with routine.env to mirror that behavior. + await db.insert(routineRuns).values({ + id: randomUUID(), + companyId, + routineId: routine.id, + triggerId: null, + source: "manual", + status: "issue_created", + triggeredAt: new Date(), + completedAt: new Date(), + routineRevisionId: null, + }); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toMatchObject({ LEGACY: "legacy-value" }); + }); + + it("routines created with null env (no Secrets tab interaction) still resolve normally with empty env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "null env routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + expect(routine.env ?? null).toBeNull(); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toHaveLength(0); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { AGENT_ONLY: "agent" } }, + projectEnv: null, + routineEnv: null, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent" }); + expect(result.secretKeys.size).toBe(0); + }); +}); diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index e5b00cff..89c4e751 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -70,7 +70,9 @@ describe("redaction", () => { const input = [ "Authorization: Bearer live-bearer-token-value", `payload {"apiKey":"json-secret-value"}`, + `paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`, `escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`, + `export PAPERCLIP_API_KEY='paperclip-shell-secret'`, `GITHUB_TOKEN=${githubToken}`, `session=${jwt}`, ].join("\n"); @@ -80,7 +82,9 @@ describe("redaction", () => { expect(result).toContain(REDACTED_EVENT_VALUE); expect(result).not.toContain("live-bearer-token-value"); expect(result).not.toContain("json-secret-value"); + expect(result).not.toContain("paperclip-json-secret"); expect(result).not.toContain("escaped-json-secret"); + expect(result).not.toContain("paperclip-shell-secret"); expect(result).not.toContain(githubToken); expect(result).not.toContain(jwt); }); diff --git a/server/src/__tests__/resource-memberships-routes.test.ts b/server/src/__tests__/resource-memberships-routes.test.ts new file mode 100644 index 00000000..9b0278ef --- /dev/null +++ b/server/src/__tests__/resource-memberships-routes.test.ts @@ -0,0 +1,218 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentMemberships, + agents, + companies, + createDb, + projectMemberships, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { resourceMembershipRoutes } from "../routes/resource-memberships.js"; +import { errorHandler } from "../middleware/index.js"; +import { resourceMembershipService } from "../services/resource-memberships.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres resource membership tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function boardActor(companyId: string, role: "admin" | "operator" | "viewer" = "viewer") { + return { + type: "board" as const, + userId: "user-1", + source: "session" as const, + isInstanceAdmin: false, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: role, status: "active" }], + }; +} + +function createApp(db: ReturnType, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", resourceMembershipRoutes(db)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("resource membership routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-resource-memberships-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(projectMemberships); + await db.delete(agentMemberships); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const projectId = randomUUID(); + const otherProjectId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + await db.insert(projects).values([ + { id: projectId, companyId, name: "Growth", status: "in_progress" }, + { id: otherProjectId, companyId: otherCompanyId, name: "Other", status: "in_progress" }, + ]); + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId: otherCompanyId, + name: "OtherAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + return { companyId, otherAgentId, otherProjectId, projectId, agentId }; + } + + it("defaults missing membership rows to joined", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + projectMemberships: {}, + agentMemberships: {}, + updatedAt: null, + }); + }); + + it("allows viewer self-service mutations, logs changes, and keeps repeats idempotent", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId, "viewer")); + + const first = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ state: "left" }); + const second = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${projectId}`) + .send({ state: "left" }); + + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ resourceType: "project", resourceId: projectId, state: "left" }); + expect(second.status).toBe(200); + + const rows = await db.select().from(projectMemberships); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ companyId, projectId, userId: "user-1", state: "left" }); + + const activity = await db.select().from(activityLog); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + companyId, + actorType: "user", + actorId: "user-1", + action: "resource_membership.left", + entityType: "project", + entityId: projectId, + }); + }); + + it("rejects agent API key actors", async () => { + const { companyId, agentId } = await seed(); + const app = createApp(db, { + type: "agent", + agentId, + companyId, + source: "agent_key", + }); + + const res = await request(app).get(`/api/companies/${companyId}/resource-memberships/me`); + + expect(res.status).toBe(403); + }); + + it("rejects cross-company target resources", async () => { + const { companyId, otherAgentId, otherProjectId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const projectRes = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/projects/${otherProjectId}`) + .send({ state: "left" }); + const agentRes = await request(app) + .put(`/api/companies/${companyId}/resource-memberships/me/agents/${otherAgentId}`) + .send({ state: "left" }); + + expect(projectRes.status).toBe(404); + expect(agentRes.status).toBe(404); + await expect(db.select().from(projectMemberships)).resolves.toHaveLength(0); + await expect(db.select().from(agentMemberships)).resolves.toHaveLength(0); + }); + + it("denies direct service calls that try to mutate another user's membership", async () => { + const { companyId, projectId } = await seed(); + const svc = resourceMembershipService(db); + + await expect( + svc.updateProject({ + companyId, + projectId, + userId: "other-user", + state: "left", + actor: boardActor(companyId), + }), + ).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 70fe9d05..e449bd30 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -5,6 +5,7 @@ import { activityLog, agents, companies, + companySecretBindings, companySecrets, companySecretVersions, createDb, @@ -19,6 +20,7 @@ import { routineRuns, routines, routineTriggers, + secretAccessEvents, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -28,6 +30,7 @@ import { issueService } from "../services/issues.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import * as providerRegistry from "../secrets/provider-registry.ts"; import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -57,6 +60,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await db.delete(activityLog); await db.delete(issueInboxArchives); await db.delete(issueReadStates); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); await db.delete(routineRuns); await db.delete(routineTriggers); await db.delete(routines); @@ -331,6 +336,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); }); + it("stores routine env in revisions, syncs routine secret bindings, and stamps runs with the dispatch revision", async () => { + const { agentId, companyId, projectId, svc } = await seedFixture(); + const secrets = secretService(db); + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: "secret-value", + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "secret routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "always_enqueue", + catchUpPolicy: "skip_missed", + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "plain-value" }, + }, + }, + {}, + ); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const [initialRevision] = await svc.listRevisions(routine.id); + expect(initialRevision?.snapshot.routine.env).toEqual(routine.env); + + await db.delete(companySecretBindings).where(eq(companySecretBindings.targetId, routine.id)); + const repaired = await svc.update(routine.id, { env: routine.env }, {}); + expect(repaired).not.toBeNull(); + const repairedBindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(repairedBindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const currentRoutine = repaired ?? routine; + const runBefore = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runBefore.routineRevisionId).toBe(currentRoutine.latestRevisionId); + + const updated = await svc.update( + routine.id, + { + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "changed" }, + }, + }, + {}, + ); + expect(updated?.latestRevisionNumber).toBe(currentRoutine.latestRevisionNumber + 1); + + const runAfter = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId); + expect(runAfter.dispatchFingerprint).not.toBe(runBefore.dispatchFingerprint); + }); + it("rejects stale routine baseRevisionId updates", async () => { const { routine, svc } = await seedFixture(); const updated = await svc.update(routine.id, { description: "new description" }, {}); diff --git a/server/src/__tests__/run-continuations.test.ts b/server/src/__tests__/run-continuations.test.ts index 7daddf7b..73423003 100644 --- a/server/src/__tests__/run-continuations.test.ts +++ b/server/src/__tests__/run-continuations.test.ts @@ -76,12 +76,11 @@ describe("run liveness continuations", () => { continuationAttempt: 1, maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, instruction: "Take the first concrete action now.", - modelProfile: "cheap", }); + expect(decision.payload).not.toHaveProperty("modelProfile"); expect(decision.contextSnapshot).toMatchObject({ issueId, wakeReason: RUN_LIVENESS_CONTINUATION_REASON, - modelProfile: "cheap", livenessContinuationAttempt: 1, livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, livenessContinuationSourceRunId: runId, @@ -89,6 +88,7 @@ describe("run liveness continuations", () => { livenessContinuationReason: "Planned without acting", livenessContinuationInstruction: "Take the first concrete action now.", }); + expect(decision.contextSnapshot).not.toHaveProperty("modelProfile"); }); it("enqueues the second empty_response continuation", () => { diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 86d4b7cb..33bdfc67 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -9,10 +9,12 @@ const mockSecretService = vi.hoisted(() => ({ listProviders: vi.fn(), checkProviders: vi.fn(), listProviderConfigs: vi.fn(), + previewProviderConfigDiscovery: vi.fn(), getProviderConfigById: vi.fn(), createProviderConfig: vi.fn(), updateProviderConfig: vi.fn(), disableProviderConfig: vi.fn(), + removeProviderConfig: vi.fn(), setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), getById: vi.fn(), @@ -117,6 +119,22 @@ describe("secret routes", () => { expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled(); }); + it("rejects provider vault discovery preview for non-board actors", async () => { + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + })) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + }); + + expect(res.status).toBe(403); + expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled(); + }); + it("rejects sensitive provider vault config fields", async () => { const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ provider: "aws_secrets_manager", @@ -132,6 +150,92 @@ describe("secret routes", () => { expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled(); }); + it("rejects sensitive provider vault discovery draft config fields", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { + region: "us-east-1", + secretAccessKey: "secret", + }, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/sensitive field/i); + expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled(); + }); + + it("previews provider vault discovery and logs only aggregate metadata", async () => { + mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({ + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 2, + skippedForeignPaperclipSampleCount: 0, + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + }, + sampleCount: 2, + samples: [ + { name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + hasKmsKey: false, + sampleCount: 2, + paperclipManagedSampleCount: 0, + skippedForeignPaperclipSampleCount: 0, + }, + warnings: [], + }, + ], + warnings: [], + }); + + const res = await request(createApp()) + .post("/api/companies/company-1/secret-provider-configs/discovery/preview") + .send({ + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "paperclip", + pageSize: 25, + }); + + expect(res.status).toBe(200); + expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "paperclip", + nextToken: undefined, + pageSize: 25, + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret_provider_config.discovery_previewed", + entityType: "secret_provider_config_discovery", + entityId: "company-1", + details: { + provider: "aws_secrets_manager", + candidateCount: 1, + sampledSecretCount: 2, + warningCount: 0, + }, + })); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai"); + }); + it("rejects ready status for coming-soon provider vaults", async () => { const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({ provider: "vault", @@ -241,6 +345,48 @@ describe("secret routes", () => { expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey"); }); + it("removes provider vault config locally without deleting remote provider data", async () => { + const createdAt = new Date("2026-05-06T00:00:00.000Z"); + const providerConfig = { + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + provider: "aws_secrets_manager", + displayName: "AWS prod", + status: "ready", + isDefault: false, + config: { region: "us-east-1" }, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + disabledAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + createdAt, + updatedAt: createdAt, + }; + mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig); + mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig); + + const res = await request(createApp()).delete( + "/api/secret-provider-configs/11111111-1111-4111-8111-111111111111", + ); + + expect(res.status).toBe(200); + expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + ); + expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled(); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "secret_provider_config.removed", + details: { + provider: "aws_secrets_manager", + displayName: "AWS prod", + remoteDeleted: false, + }, + })); + }); + it("rejects remote import preview for non-board actors", async () => { const res = await request(createApp({ type: "agent", diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 01f13041..c9513079 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -205,6 +205,116 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(events)).not.toContain("runtime-secret"); }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-secret-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "routine", + consumerId: "routine-1", + actorType: "agent", + actorId: "agent-1", + }); + + expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret"); + expect(resolved.manifest).toEqual([ + expect.objectContaining({ + configPath: "env.ROUTINE_API_KEY", + envKey: "ROUTINE_API_KEY", + secretId: secret.id, + outcome: "success", + }), + ]); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "routine", + consumerId: "routine-1", + configPath: "env.ROUTINE_API_KEY", + actorType: "agent", + actorId: "agent-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + }); + + it("records stable redacted failure codes for routine env secret resolution", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-failure-codes-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + const context = { + consumerType: "routine" as const, + consumerId: "routine-1", + actorType: "agent" as const, + actorId: "agent-1", + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + await expect( + svc.resolveEnvBindings(companyId, env, { ...context, consumerId: "routine-2" }), + ).rejects.toThrow(/not bound/i); + + await db.update(companySecrets).set({ status: "disabled" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not active/i); + + await db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, secret.id)); + await expect( + svc.resolveSecretValue(companyId, secret.id, 999, { + ...context, + configPath: "env.ROUTINE_API_KEY", + }), + ).rejects.toThrow(/version not found/i); + + await db + .update(companySecretVersions) + .set({ status: "disabled" }) + .where(eq(companySecretVersions.secretId, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/version is not active/i); + + await db + .update(companySecretVersions) + .set({ status: "current" }) + .where(eq(companySecretVersions.secretId, secret.id)); + vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce( + new Error("provider leaked value routine-super-secret"), + ); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/provider leaked value/i); + + await db.update(companySecrets).set({ status: "deleted" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not found/i); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events.map((event) => event.errorCode).sort()).toEqual([ + "binding_missing", + "provider_error", + "secret_deleted", + "secret_inactive", + "version_inactive", + "version_missing", + ]); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + expect(JSON.stringify(events)).not.toContain("provider leaked value"); + }); + it("scopes env binding sync deletes to the env path prefix", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -382,6 +492,35 @@ describeEmbeddedPostgres("secretService", () => { ); }); + it("removes provider vault config locally without deleting remote AWS secrets", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const vault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { region: "us-east-1", namespace: "prod-use1" }, + }); + const secret = await svc.create(companyId, { + name: `external-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: vault.id, + managedMode: "external_reference", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/external", + }); + const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue(); + + const removed = await svc.removeProviderConfig(vault.id); + + expect(removed?.id).toBe(vault.id); + await expect(svc.getProviderConfigById(vault.id)).resolves.toBeNull(); + const [persistedSecret] = await db + .select() + .from(companySecrets) + .where(eq(companySecrets.id, secret.id)); + expect(persistedSecret?.providerConfigId).toBeNull(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + it("hides soft-deleted secrets and allows name/key reuse", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -1097,6 +1236,111 @@ describeEmbeddedPostgres("secretService", () => { expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); }); + it("previews AWS provider vault discovery from draft config without persisting a provider vault", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const discoverSpy = vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockResolvedValue({ + provider: "aws_secrets_manager", + nextToken: null, + sampledSecretCount: 1, + skippedForeignPaperclipSampleCount: 0, + candidates: [ + { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + kmsKeyId: null, + ownerTag: "platform", + environmentTag: "production", + }, + sampleCount: 1, + samples: [ + { name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["paperclip:environment"] }, + ], + signals: { + namespace: "prod-use1", + secretNamePrefix: "paperclip", + environmentTag: "production", + ownerTag: "platform", + kmsKeyId: null, + hasKmsKey: false, + sampleCount: 1, + paperclipManagedSampleCount: 0, + skippedForeignPaperclipSampleCount: 0, + }, + warnings: [], + }, + ], + warnings: [], + }); + + const preview = await svc.previewProviderConfigDiscovery(companyId, { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + query: "openai", + pageSize: 25, + }); + + expect(discoverSpy).toHaveBeenCalledWith({ + companyId, + providerConfig: { + id: `discovery-preview-${companyId}`, + provider: "aws_secrets_manager", + status: "ready", + config: { region: "us-east-1" }, + }, + query: "openai", + nextToken: undefined, + pageSize: 25, + }); + expect(preview.candidates[0]?.config).toMatchObject({ + region: "us-east-1", + namespace: "prod-use1", + }); + expect(JSON.stringify(preview)).not.toContain("runtime-secret"); + const persistedVaults = await db.select().from(companySecretProviderConfigs); + expect(persistedVaults).toHaveLength(0); + }); + + it("sanitizes AWS provider vault discovery errors before crossing the service boundary", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const rawProviderMessage = + "AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets"; + + vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockRejectedValueOnce( + new SecretProviderClientError({ + code: "access_denied", + provider: "aws_secrets_manager", + operation: "discoverProviderConfigs", + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + rawMessage: rawProviderMessage, + }), + ); + + let thrown: unknown; + try { + await svc.previewProviderConfigDiscovery(companyId, { + provider: "aws_secrets_manager", + config: { region: "us-east-1" }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + status: 403, + message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", + details: { code: "access_denied" }, + }); + expect(JSON.stringify(thrown)).not.toContain("arn:aws"); + expect(JSON.stringify(thrown)).not.toContain("123456789012"); + expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws"); + }); + it("imports AWS remote references row-by-row without fetching plaintext", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 0cf1e664..3cdab7c4 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -138,6 +138,10 @@ vi.mock("../realtime/live-events-ws.js", () => ({ })); vi.mock("../services/index.js", () => ({ + backfillPrincipalAccessCompatibility: vi.fn(async () => ({ + agentMembershipsInserted: 0, + humanGrantsInserted: 0, + })), feedbackService: feedbackServiceFactoryMock, heartbeatService: vi.fn(() => ({ reapOrphanedRuns: vi.fn(async () => undefined), @@ -162,6 +166,7 @@ vi.mock("../services/index.js", () => ({ }, })), })), + reconcileCloudUpstreamRunsOnStartup: vi.fn(async () => ({ reconciled: 0 })), reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })), routineService: vi.fn(() => ({ tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), @@ -216,6 +221,35 @@ describe("startServer feedback export wiring", () => { serverPort: 3210, }); }); + + it("refuses authenticated public startup without an external database URL", async () => { + loadConfigMock.mockReturnValue(buildTestConfig({ + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://tenant.example.com", + databaseMode: "embedded-postgres", + databaseUrl: undefined, + })); + + await expect(startServer()).rejects.toThrow( + "authenticated public deployments require DATABASE_URL or config.database.connectionString", + ); + expect(createDbMock).not.toHaveBeenCalled(); + }); + + it("refuses authenticated public startup when DATABASE_URL is not a postgres URL", async () => { + loadConfigMock.mockReturnValue(buildTestConfig({ + deploymentExposure: "public", + authBaseUrlMode: "explicit", + authPublicBaseUrl: "https://tenant.example.com", + databaseUrl: "secret://paperclip-cloud/stacks/alpha/database/runtime-url", + })); + + await expect(startServer()).rejects.toThrow( + "authenticated public deployments require DATABASE_URL to be a postgres/postgresql connection string", + ); + expect(createDbMock).not.toHaveBeenCalled(); + }); }); describe("startServer authenticated auth origin setup", () => { diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts new file mode 100644 index 00000000..1ad3c405 --- /dev/null +++ b/server/src/__tests__/static-index-html.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { readBrandedStaticIndexHtml } from "../static-index-html.js"; + +describe("static SPA fallback HTML", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("serves the current index.html instead of reusing stale asset hashes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-static-index-")); + tempDirs.push(tempDir); + const indexPath = path.join(tempDir, "index.html"); + const app = express(); + app.get(/.*/, (_req, res) => { + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(readBrandedStaticIndexHtml(tempDir)); + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + await expect(request(app).get("/PAP/issues/PAP-9939")).resolves.toMatchObject({ + text: expect.stringContaining("/assets/index-old.js"), + }); + + fs.writeFileSync( + indexPath, + '', + "utf8", + ); + const res = await request(app).get("/PAP/issues/PAP-9939"); + expect(res.text).toContain("/assets/index-new.js"); + expect(res.text).not.toContain("/assets/index-old.js"); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 69a9c5ee..42a9955e 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -210,6 +210,56 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("ignores stale migrated env paths when the dev runner resolved the local config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-migrated-env-")); + const worktreeRoot = path.join(tempRoot, "PAP-9940-what-can-we-learn"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const oldHome = "/old/home/.paperclip-worktrees"; + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(oldHome), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_HOME=/old/home/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=pap-9940-what-can-we-learn", + "PAPERCLIP_CONFIG=/old/home/paperclip/.paperclip/worktrees/PAP-9940-what-can-we-learn/.paperclip/config.json", + "PAPERCLIP_CONTEXT=/old/home/.paperclip-worktrees/context.json", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-9940-what-can-we-learn", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_CONFIG = configPath; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-9940-what-can-we-learn"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); + expect(repairedEnv).not.toContain("/old/home"); + }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); @@ -508,6 +558,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, @@ -590,6 +642,8 @@ describe("worktree config repair", () => { process.env.PAPERCLIP_HOME = isolatedHome; process.env.PAPERCLIP_INSTANCE_ID = "pap-125-public-base-url"; process.env.PAPERCLIP_CONFIG = configPath; + delete process.env.PORT; + delete process.env.DATABASE_URL; maybePersistWorktreeRuntimePorts({ serverPort: 3103, diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index a30ed5cc..bb96eb99 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "cursor_cloud", "cursor", "gemini_local", + "grok_local", "openclaw_gateway", "opencode_local", "pi_local", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 32a748b6..abe73ea0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -78,6 +78,17 @@ import { models as geminiModels, modelProfiles as geminiModelProfiles, } from "@paperclipai/adapter-gemini-local"; +import { + execute as grokExecute, + listGrokSkills, + syncGrokSkills, + testEnvironment as grokTestEnvironment, + sessionCodec as grokSessionCodec, +} from "@paperclipai/adapter-grok-local/server"; +import { + agentConfigurationDoc as grokAgentConfigurationDoc, + models as grokModels, +} from "@paperclipai/adapter-grok-local"; import { execute as openCodeExecute, listOpenCodeSkills, @@ -349,6 +360,27 @@ const geminiLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: geminiAgentConfigurationDoc, }; +const grokLocalAdapter: ServerAdapterModule = { + type: "grok_local", + execute: grokExecute, + testEnvironment: grokTestEnvironment, + listSkills: listGrokSkills, + syncSkills: syncGrokSkills, + sessionCodec: grokSessionCodec, + sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined, + models: grokModels, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => ({ + command: readConfiguredCommand(config, "grok"), + detectCommand: readConfiguredCommand(config, "grok"), + installCommand: null, + }), + agentConfigurationDoc: grokAgentConfigurationDoc, +}; + const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -486,6 +518,7 @@ function registerBuiltInAdapters() { cursorCloudAdapter, cursorLocalAdapter, geminiLocalAdapter, + grokLocalAdapter, openclawGatewayAdapter, hermesLocalAdapter, processAdapter, diff --git a/server/src/app.ts b/server/src/app.ts index d0377184..f3a0867f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -28,6 +28,7 @@ import { dashboardRoutes } from "./routes/dashboard.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; +import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { @@ -41,6 +42,7 @@ import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; +import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; @@ -59,6 +61,8 @@ import { pluginRegistryService } from "./services/plugin-registry.js"; import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js"; +import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js"; +import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js"; type UiMode = "none" | "static" | "vite-dev"; const FEEDBACK_EXPORT_FLUSH_INTERVAL_MS = 5_000; @@ -81,6 +85,12 @@ const VITE_DEV_STATIC_PATHS = new Set([ "/sw.js", ]); +export function isDatabaseConnectionUnavailableError(err: unknown): boolean { + const error = err as { code?: unknown; message?: unknown; cause?: unknown }; + if (error?.code === "ECONNREFUSED") return true; + return Boolean(error?.cause && isDatabaseConnectionUnavailableError(error.cause)); +} + export function resolveViteHmrPort(serverPort: number): number { if (serverPort <= 55_535) { return serverPort + 10_000; @@ -88,6 +98,12 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } +export function resolveViteHmrHost(bindHost: string): string | undefined { + const normalized = bindHost.trim().toLowerCase(); + if (normalized === "0.0.0.0" || normalized === "::") return undefined; + return bindHost; +} + export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; @@ -136,13 +152,17 @@ export async function createApp( }, ) { const app = express(); + const captureRawBody = (req: express.Request, _res: express.Response, buf: Buffer) => { + (req as unknown as { rawBody: Buffer }).rawBody = buf; + }; + app.use(COMPANY_IMPORT_API_PATH, express.json({ + limit: PORTABLE_JSON_BODY_LIMIT, + verify: captureRawBody, + })); app.use(express.json({ - // Company import/export payloads can inline full portable packages. - limit: "10mb", - verify: (req, _res, buf) => { - (req as unknown as { rawBody: Buffer }).rawBody = buf; - }, + limit: DEFAULT_JSON_BODY_LIMIT, + verify: captureRawBody, })); app.use(httpLogger); const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ @@ -209,6 +229,7 @@ export async function createApp( api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); + api.use(resourceMembershipRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); if (opts.databaseBackupService) { @@ -310,7 +331,6 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { - const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); // Hashed asset files (Vite emits them under /assets/..) // never change once built, so they can be cached aggressively. app.use( @@ -350,7 +370,7 @@ export async function createApp( .status(200) .set("Content-Type", "text/html") .set("Cache-Control", "no-cache") - .end(indexHtml); + .end(readBrandedStaticIndexHtml(uiDist)); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); @@ -361,6 +381,7 @@ export async function createApp( const uiRoot = path.resolve(__dirname, "../../ui"); const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); + const hmrHost = resolveViteHmrHost(opts.bindHost); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ root: uiRoot, @@ -368,7 +389,7 @@ export async function createApp( server: { middlewareMode: true, hmr: { - host: opts.bindHost, + ...(hmrHost ? { host: hmrHost } : {}), port: hmrPort, clientPort: hmrPort, }, @@ -404,18 +425,37 @@ export async function createApp( jobCoordinator.start(); scheduler.start(); - const feedbackExportTimer = opts.feedbackExportService + let feedbackExportShuttingDown = false; + let feedbackExportTimer: ReturnType | null = null; + const disableFeedbackExportFlushes = () => { + feedbackExportShuttingDown = true; + if (feedbackExportTimer) { + clearInterval(feedbackExportTimer); + feedbackExportTimer = null; + } + }; + const flushPendingFeedbackExports = async () => { + if (feedbackExportShuttingDown) return; + try { + await opts.feedbackExportService?.flushPendingFeedbackTraces(); + } catch (err) { + if (isDatabaseConnectionUnavailableError(err)) { + disableFeedbackExportFlushes(); + logger.warn({ err }, "Disabling pending feedback export flushes because the database is unavailable"); + return; + } + logger.error({ err }, "Failed to flush pending feedback exports"); + } + }; + + feedbackExportTimer = opts.feedbackExportService ? setInterval(() => { - void opts.feedbackExportService?.flushPendingFeedbackTraces().catch((err) => { - logger.error({ err }, "Failed to flush pending feedback exports"); - }); + void flushPendingFeedbackExports(); }, FEEDBACK_EXPORT_FLUSH_INTERVAL_MS) : null; feedbackExportTimer?.unref?.(); if (opts.feedbackExportService) { - void opts.feedbackExportService.flushPendingFeedbackTraces().catch((err) => { - logger.error({ err }, "Failed to flush pending feedback exports"); - }); + void flushPendingFeedbackExports(); } void toolDispatcher.initialize().catch((err) => { logger.error({ err }, "Failed to initialize plugin tool dispatcher"); @@ -434,13 +474,19 @@ export async function createApp( }).catch((err) => { logger.error({ err }, "Failed to load ready plugins on startup"); }); - process.once("exit", () => { - if (feedbackExportTimer) clearInterval(feedbackExportTimer); + let appServicesShutdown = false; + const shutdownAppServices = () => { + if (appServicesShutdown) return; + appServicesShutdown = true; + disableFeedbackExportFlushes(); devWatcher?.close(); viteHtmlRenderer?.dispose(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); - }); + }; + app.locals.paperclipShutdown = shutdownAppServices; + + process.once("exit", shutdownAppServices); process.once("beforeExit", () => { void flushPluginLogBuffer(); }); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 3c84b9f5..f7742b97 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -44,6 +44,28 @@ export function buildBetterAuthAdvancedOptions(input: { disableSecureCookies: bo }; } +export function shouldDisableSecureAuthCookies(input: { + deploymentMode: Config["deploymentMode"]; + deploymentExposure?: Config["deploymentExposure"]; + authBaseUrlMode: Config["authBaseUrlMode"]; + authPublicBaseUrl: string | undefined; + publicUrl?: string | undefined; +}): boolean { + const publicUrl = ( + input.publicUrl?.trim() || + (input.authBaseUrlMode === "explicit" ? input.authPublicBaseUrl?.trim() : "") + ); + if (publicUrl) return publicUrl.startsWith("http://"); + + return ( + input.deploymentMode === "authenticated" && + ( + (input.deploymentExposure === "private" && input.authBaseUrlMode === "auto") || + input.deploymentExposure === undefined + ) + ); +} + function headersFromNodeHeaders(rawHeaders: IncomingHttpHeaders): Headers { const headers = new Headers(); for (const [key, raw] of Object.entries(rawHeaders)) { @@ -92,6 +114,7 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) { throw new Error( @@ -99,8 +122,13 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: "For local development, set BETTER_AUTH_SECRET=paperclip-dev-secret in your .env file.", ); } - const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl; - const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false; + const disableSecureCookies = shouldDisableSecureAuthCookies({ + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl, + publicUrl, + }); const authConfig = { baseURL: baseUrl, @@ -120,7 +148,7 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: requireEmailVerification: false, disableSignUp: config.authDisableSignUp, }, - advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: isHttpOnly }), + advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }), }; if (!baseUrl) { diff --git a/server/src/board-claim.ts b/server/src/board-claim.ts index 4c87b732..c4784406 100644 --- a/server/src/board-claim.ts +++ b/server/src/board-claim.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import type { DeploymentMode } from "@paperclipai/shared"; +import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js"; const LOCAL_BOARD_USER_ID = "local-board"; const CLAIM_TTL_MS = 1000 * 60 * 60 * 24; @@ -89,6 +90,7 @@ export async function claimBoardOwnership( const status = getChallengeStatus(opts.token, opts.code); if (status !== "available") return { status }; + const claimedCompanyIds: string[] = []; await db.transaction(async (tx) => { const existingTargetAdmin = await tx .select({ id: instanceUserRoles.id }) @@ -108,6 +110,7 @@ export async function claimBoardOwnership( const allCompanies = await tx.select({ id: companies.id }).from(companies); for (const company of allCompanies) { + claimedCompanyIds.push(company.id); const existing = await tx .select({ id: companyMemberships.id, status: companyMemberships.status }) .from(companyMemberships) @@ -140,6 +143,15 @@ export async function claimBoardOwnership( } }); + for (const companyId of claimedCompanyIds) { + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: opts.userId, + membershipRole: "owner", + grantedByUserId: opts.userId, + }); + } + if (activeChallenge && activeChallenge.token === opts.token) { activeChallenge.claimedAt = new Date(); activeChallenge.claimedByUserId = opts.userId; diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 4e2b5d8d..7779c4e2 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -1,4 +1,5 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; function parseEnvFile(contents: string): Record { @@ -55,6 +56,45 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { return path.resolve(rootDir, ".paperclip", ".env"); } +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function resolveDefaultWorktreeHome(env: NodeJS.ProcessEnv): string { + return path.resolve(expandHomePrefix(env.PAPERCLIP_WORKTREES_DIR?.trim() || "~/.paperclip-worktrees")); +} + +function repairStaleMigratedWorktreeEnvEntries( + rootDir: string, + entries: Record, + env: NodeJS.ProcessEnv, +): Record { + const localConfigPath = path.resolve(rootDir, ".paperclip", "config.json"); + const configuredPath = entries.PAPERCLIP_CONFIG?.trim(); + if (!configuredPath) return entries; + + const resolvedConfiguredPath = resolveHomeAwarePath(configuredPath); + const staleConfigPath = + resolvedConfiguredPath !== localConfigPath && + !existsSync(resolvedConfiguredPath) && + existsSync(localConfigPath); + if (!staleConfigPath) return entries; + + const homeDir = resolveDefaultWorktreeHome(env); + return { + ...entries, + PAPERCLIP_HOME: homeDir, + PAPERCLIP_CONFIG: localConfigPath, + PAPERCLIP_CONTEXT: path.resolve(homeDir, "context.json"), + }; +} + export function bootstrapDevRunnerWorktreeEnv( rootDir: string, env: NodeJS.ProcessEnv = process.env, @@ -74,7 +114,11 @@ export function bootstrapDevRunnerWorktreeEnv( }; } - const entries = parseEnvFile(readFileSync(envPath, "utf8")); + const entries = repairStaleMigratedWorktreeEnvEntries( + rootDir, + parseEnvFile(readFileSync(envPath, "utf8")), + env, + ); for (const [key, value] of Object.entries(entries)) { if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; env[key] = value; diff --git a/server/src/dev-server-status.ts b/server/src/dev-server-status.ts index ec78bfe8..2e20a441 100644 --- a/server/src/dev-server-status.ts +++ b/server/src/dev-server-status.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024; @@ -25,6 +26,31 @@ export type DevServerHealthStatus = { lastRestartAt: string | null; }; +export type DevServerRestartRequest = { + requestedAt: string; + reason: "manual_restart_now"; +}; + +export function getDevServerRestartRequestFilePath( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim(); + if (!statusFilePath) return null; + return path.join(path.dirname(statusFilePath), "dev-server-restart-request.json"); +} + +export function writeDevServerRestartRequest( + request: DevServerRestartRequest, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const filePath = getDevServerRestartRequestFilePath(env); + if (!filePath) return false; + + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(request, null, 2)}\n`, "utf8"); + return true; +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value diff --git a/server/src/http/body-limits.ts b/server/src/http/body-limits.ts new file mode 100644 index 00000000..12c45705 --- /dev/null +++ b/server/src/http/body-limits.ts @@ -0,0 +1,3 @@ +export const DEFAULT_JSON_BODY_LIMIT = "10mb"; +export const PORTABLE_JSON_BODY_LIMIT = "64mb"; +export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024; diff --git a/server/src/index.ts b/server/src/index.ts index 105f23ec..38caf44a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,7 @@ import { inspectMigrations, applyPendingMigrations, createEmbeddedPostgresLogBuffer, + prepareEmbeddedPostgresNativeRuntime, reconcilePendingMigrationHistory, formatDatabaseBackupResult, runDatabaseBackup, @@ -30,8 +31,10 @@ import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, + backfillPrincipalAccessCompatibility, heartbeatService, instanceSettingsService, + reconcileCloudUpstreamRunsOnStartup, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -187,6 +190,31 @@ export async function startServer(): Promise { return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; } + function isPostgresConnectionString(connectionString: string): boolean { + try { + const parsed = new URL(connectionString); + return parsed.protocol === "postgres:" || parsed.protocol === "postgresql:"; + } catch { + return false; + } + } + + function assertCloudDatabaseContract(): void { + if (config.deploymentMode !== "authenticated" || config.deploymentExposure !== "public") { + return; + } + if (!config.databaseUrl) { + throw new Error( + "authenticated public deployments require DATABASE_URL or config.database.connectionString; refusing embedded PostgreSQL fallback", + ); + } + if (!isPostgresConnectionString(config.databaseUrl)) { + throw new Error( + "authenticated public deployments require DATABASE_URL to be a postgres/postgresql connection string", + ); + } + } + function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { if (!rawUrl) return undefined; try { @@ -270,6 +298,7 @@ export async function startServer(): Promise { let startupDbInfo: | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; + assertCloudDatabaseContract(); if (config.databaseUrl) { const migrationUrl = config.databaseMigrationUrl ?? config.databaseUrl; migrationSummary = await ensureMigrations(migrationUrl, "PostgreSQL"); @@ -290,6 +319,7 @@ export async function startServer(): Promise { "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", ); } + await prepareEmbeddedPostgresNativeRuntime(); const dataDir = resolve(config.embeddedPostgresDataDir); const configuredPort = config.embeddedPostgresPort; @@ -486,6 +516,10 @@ export async function startServer(): Promise { if (config.deploymentMode === "local_trusted") { await ensureLocalTrustedBoardPrincipal(db as any); } + const accessBackfill = await backfillPrincipalAccessCompatibility(db as any); + if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) { + logger.info(accessBackfill, "Backfilled principal access compatibility records"); + } if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, @@ -668,6 +702,19 @@ export async function startServer(): Promise { .catch((err) => { logger.error({ err }, "startup reconciliation of persisted runtime services failed"); }); + + void reconcileCloudUpstreamRunsOnStartup(db as any) + .then((result) => { + if (result.reconciled > 0) { + logger.warn( + { reconciled: result.reconciled }, + "reconciled cloud upstream runs from a previous server process", + ); + } + }) + .catch((err) => { + logger.error({ err }, "startup reconciliation of cloud upstream runs failed"); + }); if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any, { pluginWorkerManager }); @@ -878,6 +925,9 @@ export async function startServer(): Promise { await telemetryClient.flush(); } + const appShutdown = (app as { locals?: { paperclipShutdown?: () => void } }).locals?.paperclipShutdown; + appShutdown?.(); + if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { logger.info({ signal }, "Stopping embedded PostgreSQL"); try { diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index 96e2a461..a6347b86 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -56,9 +56,14 @@ export function boardMutationGuard(): RequestHandler { return; } - // Local-trusted mode and board bearer keys are not browser-session requests. + // Local-trusted mode, board bearer keys, and trusted Cloud tenant calls are + // not browser-session requests. // In these modes, origin/referer headers can be absent; do not block those mutations. - if (req.actor.source === "local_implicit" || req.actor.source === "board_key") { + if ( + req.actor.source === "local_implicit" + || req.actor.source === "board_key" + || req.actor.source === "cloud_tenant" + ) { next(); return; } diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 032b5e1f..c455cbad 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -3,6 +3,7 @@ import { ZodError } from "zod"; import { HttpError } from "../errors.js"; import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; +import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js"; export interface ErrorContext { error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown }; @@ -74,5 +75,14 @@ export function errorHandler( const tc = getTelemetryClient(); if (tc) trackErrorHandlerCrash(tc, { errorCode: rootError.name }); - res.status(500).json({ error: "Internal server error" }); + res.status(500).json({ + error: "Internal server error", + ...(shouldExposeTrustedCloudTenantImportError(req) ? { message: rootError.message } : {}), + }); +} + +function shouldExposeTrustedCloudTenantImportError(req: Request) { + return req.actor?.source === "cloud_tenant" + && req.method === "POST" + && req.originalUrl.split("?")[0] === COMPANY_IMPORT_API_PATH; } diff --git a/server/src/redaction.ts b/server/src/redaction.ts index f3877a8b..ebb1e663 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -1,19 +1,49 @@ import { redactCommandText } from "@paperclipai/adapter-utils"; -const SECRET_PAYLOAD_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const SECRET_FIELD_NAME_PATTERN = + String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; + +const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i"); const COMMAND_PAYLOAD_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; -const CLI_SECRET_FLAG_RE = - /^-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)$/i; -const JSON_SECRET_FIELD_TEXT_RE = - /((?:"|')?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:"|')?\s*:\s*(?:"|'))[^"'`\r\n]+((?:"|'))/gi; -const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = - /((?:\\")?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))/gi; +const CLI_SECRET_FLAG_RE = new RegExp(String.raw`^-{1,2}${SECRET_FIELD_NAME_PATTERN}$`, "i"); +const JSON_SECRET_FIELD_TEXT_RE = new RegExp( + String.raw`((?:"|')?${SECRET_FIELD_NAME_PATTERN}(?:"|')?\s*:\s*(?:"|'))[^"'` + "`" + String.raw`\r\n]+((?:"|'))`, + "gi", +); +const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = new RegExp( + String.raw`((?:\\")?${SECRET_FIELD_NAME_PATTERN}(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))`, + "gi", +); +const SECRET_TEXT_HINTS = [ + "api", + "key", + "token", + "auth", + "bearer", + "secret", + "pass", + "credential", + "jwt", + "private", + "cookie", + "connectionstring", + "sk-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", +] as const; export const REDACTED_EVENT_VALUE = "***REDACTED***"; +function maybeContainsSecretText(input: string) { + const lower = input.toLowerCase(); + return SECRET_TEXT_HINTS.some((hint) => lower.includes(hint)) || input.includes("."); +} + function isPlainObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); @@ -94,6 +124,7 @@ export function redactEventPayload(payload: Record | null): Rec } export function redactSensitiveText(input: string): string { + if (!maybeContainsSecretText(input)) return input; return redactCommandText( input .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index ac7423f3..44b1fbaf 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1554,16 +1554,17 @@ function buildInviteOnboardingManifest( ), onboarding: { instructions: - "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", + "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token.", inviteMessage: extractInviteMessage(invite), - recommendedAdapterType: "openclaw_gateway", + recommendedAdapterType: null, requiredFields: { requestType: "agent", agentName: "Display name for this agent", - adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents", + adapterType: + "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents.", capabilities: "Optional capability summary", agentDefaultsPayload: - "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem." + "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Other runtimes should include the config their adapter expects." }, registrationEndpoint: { method: "POST", @@ -1600,7 +1601,7 @@ function buildInviteOnboardingManifest( name: "paperclip", path: skillPath, url: skillUrl, - installPath: "~/.openclaw/skills/paperclip/SKILL.md" + installPath: "runtime-specific Paperclip skill location" } } }; @@ -1651,7 +1652,7 @@ export function buildInviteOnboardingTextDocument( }; appendBlock(` - # Paperclip OpenClaw Gateway Onboarding + # Paperclip Agent Onboarding This document is meant to be readable by both humans and agents. @@ -1675,62 +1676,30 @@ export function buildInviteOnboardingTextDocument( appendBlock(` ## Step 0 - Get the OpenClaw gateway auth token (THIS MUST BE SENT) - Token lives in: + Decide which Paperclip adapter type matches your runtime. - ~/.openclaw/openclaw.json -> gateway.auth.token - Extract: - - TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')" - test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1) - test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1) - - 3) IMPORTANT: Don't accidentally drop the token when generating JSON - If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var. - - Safe payload build looks sort of like this (substitute where necessary): - - BODY="$(node -e ' - const token = process.argv[1]; - if (!token) process.exit(2); - const body = { - requestType: "agent", - agentName: "OpenClaw", - adapterType: "openclaw_gateway", - capabilities: "OpenClaw agent adapter", - agentDefaultsPayload: { - url: "ws://127.0.0.1:18789", - paperclipApiUrl: "http://host.docker.internal:3100", - headers: { "x-openclaw-token": token }, - waitTimeoutMs: 120000, - sessionKeyStrategy: "issue", - role: "operator", - scopes: ["operator.admin"] - } - }; - process.stdout.write(JSON.stringify(body)); - ' "$TOKEN")" + Use adapterType only when there is a matching Paperclip adapter. Put runtime-specific settings in agentDefaultsPayload. ## Step 1: Submit agent join request ${onboarding.registrationEndpoint.method} ${ onboarding.registrationEndpoint.url } - IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. - Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. - Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL. - Pairing mode requirement: - - Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable. - - You may set disableDeviceAuth=true only for special environments that cannot support pairing. - - First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry. - Do NOT use /v1/responses or /hooks/* in this gateway join flow. - Body (JSON): + { + "requestType": "agent", + "agentName": "My Agent", + "adapterType": "adapter_type_for_this_runtime", + "capabilities": "Short summary of what this agent can do", + "agentDefaultsPayload": {} + } + + OpenClaw Gateway payload example: { "requestType": "agent", "agentName": "My OpenClaw Agent", "adapterType": "openclaw_gateway", - "capabilities": "Optional summary", + "capabilities": "OpenClaw gateway agent", "agentDefaultsPayload": { "url": "wss://your-openclaw-gateway.example", "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", @@ -1742,6 +1711,8 @@ export function buildInviteOnboardingTextDocument( } } + For OpenClaw Gateway, include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. Do NOT use /v1/responses or /hooks/* in this gateway join flow. + Expected response includes: - request id - one-time claimSecret @@ -1760,35 +1731,18 @@ export function buildInviteOnboardingTextDocument( "claimSecret": "" } - On successful claim, save the full JSON response to: - - - ~/.openclaw/workspace/paperclip-claimed-api-key.json - chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json - - And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here: - https://docs.openclaw.ai/help/environment - - e.g. - - { - env: { - PAPERCLIP_API_KEY: "...", - PAPERCLIP_API_URL: "...", - }, - } - - Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run. + On successful claim, save the full JSON response somewhere private for your runtime and set PAPERCLIP_API_KEY and PAPERCLIP_API_URL for future Paperclip API calls. Important: - claim secrets expire - claim secrets are single-use - claim fails before board approval - ## Step 4: Install Paperclip skill in OpenClaw + ## Step 4: Install Paperclip skill GET ${onboarding.skill.url} Install path: ${onboarding.skill.installPath} - Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL + Use your runtime's normal skill or instruction installation path. ## Text onboarding URL ${onboarding.textInstructions.url} @@ -2767,6 +2721,83 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function approveHumanJoinRequestFromInvite(input: { + req: Request; + invite: typeof invites.$inferSelect; + joinRequest: typeof joinRequests.$inferSelect; + companyId: string; + }) { + if (input.joinRequest.requestType !== "human") { + throw badRequest("Only human join requests can be approved through a human invite"); + } + if (!input.joinRequest.requestingUserId) { + throw conflict("Join request missing user identity"); + } + + const membershipRole = resolveHumanInviteRole( + input.invite.defaultsPayload as Record | null, + ); + await access.ensureMembership( + input.companyId, + "user", + input.joinRequest.requestingUserId, + membershipRole, + "active", + ); + const grants = humanJoinGrantsFromDefaults( + input.invite.defaultsPayload as Record | null, + membershipRole, + ); + await access.setPrincipalGrants( + input.companyId, + "user", + input.joinRequest.requestingUserId, + grants, + input.invite.invitedByUserId ?? null, + ); + + if (input.joinRequest.status === "approved") { + return input.joinRequest; + } + + const approvedAt = new Date(); + const approvedByUserId = + input.invite.invitedByUserId ?? (isLocalImplicit(input.req) ? "local-board" : null); + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }) + .where(eq(joinRequests.id, input.joinRequest.id)) + .returning() + .then((rows) => rows[0] ?? null); + + await logActivity(db, { + companyId: input.companyId, + actorType: "user", + actorId: approvedByUserId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: input.joinRequest.id, + details: { + requestType: "human", + inviteId: input.invite.id, + source: "human_invite_accept", + }, + }); + + return approved ?? { + ...input.joinRequest, + status: "approved", + approvedByUserId, + approvedAt, + updatedAt: approvedAt, + }; + } + async function getInviteCompanyBranding( companyId: string | null, inviteToken: string | null = null, @@ -3301,9 +3332,26 @@ export function accessRoutes( } } + const actorEmail = + requestType === "human" ? await resolveActorEmail(db, req) : null; + const actorRequestingUserId = + requestType === "human" + ? req.actor.userId ?? "local-board" + : null; + const canReplayHumanInviteAccept = + inviteAlreadyAccepted && + requestType === "human" && + existingJoinRequestForInvite?.requestType === "human" && + Boolean( + findReusableHumanJoinRequest([existingJoinRequestForInvite], { + requestingUserId: actorRequestingUserId, + requestEmailSnapshot: actorEmail, + }) + ); const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && + !canReplayHumanInviteAccept && !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, @@ -3382,8 +3430,6 @@ export function accessRoutes( ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) : null; - const actorEmail = - requestType === "human" ? await resolveActorEmail(db, req) : null; const existingHumanJoinRequest = requestType === "human" ? findReusableHumanJoinRequest( @@ -3398,12 +3444,12 @@ export function accessRoutes( ) .orderBy(desc(joinRequests.createdAt)), { - requestingUserId: req.actor.userId ?? "local-board", + requestingUserId: actorRequestingUserId, requestEmailSnapshot: actorEmail } ) : null; - const created = !inviteAlreadyAccepted + let created = !inviteAlreadyAccepted ? existingHumanJoinRequest ? await db.transaction(async (tx) => { await tx @@ -3612,6 +3658,15 @@ export function accessRoutes( } }); + if (requestType === "human") { + created = await approveHumanJoinRequestFromInvite({ + req, + invite, + joinRequest: created, + companyId, + }); + } + const response = toJoinRequestResponse(created); if (claimSecret) { const companyBranding = await getInviteCompanyBranding(invite.companyId); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5bf94567..7c0f9ec9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -502,6 +502,15 @@ export function agentRoutes( }; } + if (membership?.status === "active") { + return { + canAssignTasks: true, + taskAssignSource: "simple_default" as const, + membership, + grants, + }; + } + return { canAssignTasks: false, taskAssignSource: "none" as const, @@ -544,34 +553,32 @@ export function agentRoutes( async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); - if (!allowed) { - throw forbidden("Missing permission: agents:create"); - } - return null; + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation); } - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - const actorAgent = await svc.getById(req.actor.agentId); + if (req.actor.type !== "agent") return null; + const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); - if (!allowedByGrant && !canCreateAgents(actorAgent)) { - throw forbidden("Missing permission: can create agents"); - } return actorAgent; } async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); - if (!allowed) { - throw forbidden("Missing permission: agents:create"); - } + const decision = await access.decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } async function assertCanReadConfigurations(req: Request, companyId: string) { @@ -593,15 +600,12 @@ export function agentRoutes( async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; - return access.canUser(companyId, req.actor.userId, "agents:create"); - } - if (!req.actor.agentId) return false; - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== companyId) return false; - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); - return allowedByGrant || canCreateAgents(actorAgent); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "company", companyId }, + }); + return decision.allowed; } async function buildSkippedWakeupResponse( @@ -673,27 +677,13 @@ export function agentRoutes( async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") { - await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); - return; - } - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - - const actorAgent = await svc.getById(req.actor.agentId); - if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { - throw forbidden("Agent key cannot access another company"); - } - - if (actorAgent.id === targetAgent.id) return; - if (actorAgent.role === "ceo") return; - const allowedByGrant = await access.hasPermission( - targetAgent.companyId, - "agent", - actorAgent.id, - "agents:create", - ); - if (allowedByGrant || canCreateAgents(actorAgent)) return; - throw forbidden("Only CEO or agent creators can modify other agents"); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { diff --git a/server/src/routes/cloud-upstreams.ts b/server/src/routes/cloud-upstreams.ts new file mode 100644 index 00000000..085afe47 --- /dev/null +++ b/server/src/routes/cloud-upstreams.ts @@ -0,0 +1,118 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { badRequest, notFound } from "../errors.js"; +import { assertBoardOrgAccess } from "./authz.js"; +import { cloudUpstreamService, instanceSettingsService } from "../services/index.js"; + +export function cloudUpstreamRoutes(db: Db, options: { instanceId?: string } = {}) { + const router = Router(); + const service = cloudUpstreamService(db, options); + const settings = instanceSettingsService(db); + + async function assertEnabled() { + const experimental = await settings.getExperimental(); + if (experimental.enableCloudSync !== true) { + throw notFound("Cloud sync is not enabled"); + } + } + + router.get("/cloud-upstreams", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringQuery(req.query.companyId, "companyId"); + res.json(await service.list(companyId)); + }); + + router.post("/cloud-upstreams/connect/start", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + const companyId = stringBody(req.body, "companyId"); + const remoteUrl = stringBody(req.body, "remoteUrl"); + const redirectUri = stringBody(req.body, "redirectUri"); + res.json(await service.startConnect({ companyId, remoteUrl, redirectUri })); + }); + + router.post("/cloud-upstreams/connect/finish", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.finishConnect({ + pendingConnectionId: stringBody(req.body, "pendingConnectionId"), + code: stringBody(req.body, "code"), + state: stringBody(req.body, "state"), + })); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/preview", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.preview(req.params.connectionId, stringBody(req.body, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.createRun({ + connectionId: req.params.connectionId, + companyId: stringBody(req.body, "companyId"), + retryOfRunId: optionalString(req.body?.retryOfRunId), + })); + }); + + router.get("/cloud-upstreams/:connectionId/push-runs/:runId", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.readRun(req.params.connectionId, req.params.runId, stringQuery(req.query.companyId, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/:runId/cancel", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.cancelRun(req.params.connectionId, req.params.runId, stringBody(req.body, "companyId"))); + }); + + router.post("/cloud-upstreams/:connectionId/push-runs/:runId/activation", async (req, res) => { + assertBoardOrgAccess(req); + await assertEnabled(); + res.json(await service.activateRunEntities({ + connectionId: req.params.connectionId, + runId: req.params.runId, + companyId: stringBody(req.body, "companyId"), + entityType: activationEntityTypeBody(req.body), + })); + }); + + return router; +} + +function stringQuery(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${label} is required`); + } + return value; +} + +function stringBody(body: unknown, key: string): string { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest(`${key} is required`); + } + const value = (body as Record)[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw badRequest(`${key} is required`); + } + return value; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function activationEntityTypeBody(body: unknown): "agents" | "routines" | "monitors" { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest("entityType is required"); + } + const value = (body as Record).entityType; + if (value !== "agents" && value !== "routines" && value !== "monitors") { + throw badRequest("entityType must be agents, routines, or monitors"); + } + return value; +} diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 6516e5ef..3fe9664f 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { @@ -25,6 +26,7 @@ import { } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -34,6 +36,8 @@ export function companyRoutes(db: Db, storage?: StorageService) { const access = accessService(db); const budgets = budgetService(db); const feedback = feedbackService(db); + const importJobs = new Map(); + const importJobTerminalRetentionMs = 5 * 60 * 1000; function parseBooleanQuery(value: unknown) { return value === true || value === "true" || value === "1"; @@ -176,27 +180,47 @@ export function companyRoutes(db: Db, storage?: StorageService) { res.json(preview); }); - router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { + router.get("/import/jobs/:jobId", async (req, res) => { + assertCloudTenantCaller(req); + cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs); + const job = importJobs.get(req.params.jobId as string); + if (!job || job.cloudTenantKey !== cloudTenantRequestKey(req)) { + res.status(404).json({ error: "Import job not found" }); + return; + } + res.json(importJobResponse(job)); + }); + + router.post(COMPANY_IMPORT_ROUTE_PATH, async (req, res) => { assertBoard(req); - assertImportTargetAccess(req, req.body.target); + const rawImportBody: unknown = req.body; const actor = getActorInfo(req); - const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); - await logActivity(db, { - companyId: result.company.id, - actorType: actor.actorType, - actorId: actor.actorId, - action: "company.imported", - entityType: "company", - entityId: result.company.id, - agentId: actor.agentId, - runId: actor.runId, - details: { - include: req.body.include ?? null, - agentCount: result.agents.length, - warningCount: result.warnings.length, - companyAction: result.company.action, - }, - }); + const boardUserId = req.actor.type === "board" ? req.actor.userId : null; + if (req.header("x-paperclip-cloud-async-import") === "1") { + assertCloudTenantCaller(req); + cleanupTerminalImportJobs(importJobs, importJobTerminalRetentionMs); + const job = createImportJob(cloudTenantRequestKey(req)); + importJobs.set(job.id, job); + const operation = async () => { + const importBody = companyPortabilityImportSchema.parse(rawImportBody); + assertImportTargetAccess(req, importBody.target); + const activity = importedCompanyActivityContext(actor, importBody.include ?? null); + const result = await portability.importBundle(importBody, boardUserId); + await logImportedCompanyActivity(db, activity, result); + return result; + }; + res.status(202).json(importJobAcceptedResponse(job)); + setImmediate(() => { + void runImportJob(job, operation); + }); + return; + } + + const importBody = companyPortabilityImportSchema.parse(rawImportBody); + assertImportTargetAccess(req, importBody.target); + const activity = importedCompanyActivityContext(actor, importBody.include ?? null); + const result = await portability.importBundle(importBody, boardUserId); + await logImportedCompanyActivity(db, activity, result); res.json(result); }); @@ -270,7 +294,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { throw forbidden("Instance admin required"); } const company = await svc.create(req.body); - await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active"); + const ownerPrincipalId = req.actor.userId ?? "local-board"; + await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + company.id, + ownerPrincipalId, + "owner", + req.actor.userId ?? null, + ); await logActivity(db, { companyId: company.id, actorType: "user", @@ -411,3 +442,166 @@ export function companyRoutes(db: Db, storage?: StorageService) { return router; } + +type CompanyImportResult = { + company: { id: string; action: unknown }; + agents: unknown[]; + warnings: unknown[]; +}; + +interface ImportJobRecord { + id: string; + cloudTenantKey: string; + status: "running" | "succeeded" | "failed"; + createdAt: string; + updatedAt: string; + completedAt?: string; + error?: { message: string }; + result?: { + companyId: string; + agentCount: number; + warningCount: number; + companyAction: unknown; + }; +} + +interface ImportedCompanyActivityContext { + actorType: "user" | "agent"; + actorId: string; + agentId: string | null; + runId: string | null; + include: unknown; +} + +function assertCloudTenantCaller(req: Request) { + if (req.actor.source !== "cloud_tenant") { + throw forbidden("Trusted Cloud tenant access required"); + } +} + +function cloudTenantRequestKey(req: Request) { + return [ + req.actor.userId ?? "", + req.header("x-paperclip-cloud-stack-id")?.trim() ?? "", + req.header("x-paperclip-cloud-paperclip-company-id")?.trim() ?? "", + ].join(":"); +} + +function createImportJob(cloudTenantKey: string): ImportJobRecord { + const now = new Date().toISOString(); + return { + id: `tenant-import-${randomUUID()}`, + cloudTenantKey, + status: "running", + createdAt: now, + updatedAt: now, + }; +} + +async function runImportJob( + job: ImportJobRecord, + operation: () => Promise, +) { + try { + const result = await operation(); + const now = new Date().toISOString(); + job.status = "succeeded"; + job.updatedAt = now; + job.completedAt = now; + job.result = { + companyId: result.company.id, + agentCount: result.agents.length, + warningCount: result.warnings.length, + companyAction: result.company.action, + }; + } catch (error) { + const now = new Date().toISOString(); + job.status = "failed"; + job.updatedAt = now; + job.completedAt = now; + job.error = { message: errorMessage(error) }; + } +} + +function importedCompanyActivityContext( + actor: ReturnType, + include: unknown, +): ImportedCompanyActivityContext { + return { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + include, + }; +} + +async function logImportedCompanyActivity( + db: Db, + activity: ImportedCompanyActivityContext, + result: CompanyImportResult, +) { + await logActivity(db, { + companyId: result.company.id, + actorType: activity.actorType, + actorId: activity.actorId, + action: "company.imported", + entityType: "company", + entityId: result.company.id, + agentId: activity.agentId, + runId: activity.runId, + details: { + include: activity.include, + agentCount: result.agents.length, + warningCount: result.warnings.length, + companyAction: result.company.action, + }, + }); +} + +function importJobAcceptedResponse(job: ImportJobRecord) { + return { + job: { + id: job.id, + status: job.status, + }, + statusUrl: `/api/companies/import/jobs/${encodeURIComponent(job.id)}`, + retryAfterMs: 1000, + }; +} + +function importJobResponse(job: ImportJobRecord) { + const isTerminal = job.status === "succeeded" || job.status === "failed"; + const response: Record = { + job: { + id: job.id, + status: job.status, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + ...(job.completedAt ? { completedAt: job.completedAt } : {}), + ...(job.error ? { error: job.error } : {}), + ...(job.result ? { result: job.result } : {}), + }, + ...(isTerminal ? {} : { retryAfterMs: 1000 }), + }; + if (job.error?.message) { + response.error = job.error.message; + response.message = job.error.message; + response.reason = job.error.message; + } + return response; +} + +function cleanupTerminalImportJobs(importJobs: Map, terminalRetentionMs: number) { + const now = Date.now(); + for (const [jobId, job] of importJobs) { + if (job.status === "running" || !job.completedAt) continue; + if (now - Date.parse(job.completedAt) > terminalRetentionMs) { + importJobs.delete(jobId); + } + } +} + +function errorMessage(error: unknown) { + return error instanceof Error && error.message.trim() ? error.message : String(error); +} diff --git a/server/src/routes/company-import-paths.ts b/server/src/routes/company-import-paths.ts new file mode 100644 index 00000000..cf434900 --- /dev/null +++ b/server/src/routes/company-import-paths.ts @@ -0,0 +1,2 @@ +export const COMPANY_IMPORT_ROUTE_PATH = "/import"; +export const COMPANY_IMPORT_API_PATH = `/api/companies${COMPANY_IMPORT_ROUTE_PATH}`; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 388585a5..80a2dd70 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -44,6 +44,40 @@ export function healthRoutes( ) { const router = Router(); + router.post("/dev-server/restart", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + if (opts.deploymentMode === "authenticated" && actorType !== "board") { + res.status(403).json({ error: "board_access_required" }); + return; + } + + const persistedDevServerStatus = readPersistedDevServerStatus(); + if (!persistedDevServerStatus) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + const restartRequired = + persistedDevServerStatus.dirty || + persistedDevServerStatus.changedPathCount > 0 || + persistedDevServerStatus.pendingMigrations.length > 0; + if (!restartRequired) { + res.status(409).json({ error: "restart_not_required" }); + return; + } + + const written = writeDevServerRestartRequest({ + requestedAt: new Date().toISOString(), + reason: "manual_restart_now", + }); + if (!written) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + res.status(202).json({ status: "restart_requested" }); + }); + router.get("/", async (req, res) => { const actorType = "actor" in req ? req.actor?.type : null; const exposeFullDetails = shouldExposeFullHealthDetails( diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 983562e2..a816511b 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -14,8 +14,10 @@ export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { sidebarPreferenceRoutes } from "./sidebar-preferences.js"; +export { resourceMembershipRoutes } from "./resource-memberships.js"; export { inboxDismissalRoutes } from "./inbox-dismissals.js"; export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js"; +export { cloudUpstreamRoutes } from "./cloud-upstreams.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b0f61244..3e2549b3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db"; import { activityLog, executionWorkspaces, + heartbeatRuns, issueExecutionDecisions, issueRelations, issues as issueRows, @@ -117,6 +118,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({ type ParsedExecutionState = NonNullable>; type NormalizedExecutionPolicy = NonNullable>; +type IssueRouteSnapshot = typeof issueRows.$inferSelect; +type RecoveryRevalidationTrigger = + | "issue_update" + | "comment" + | "document" + | "work_product" + | "read_projection"; type CompanySearchService = { search(companyId: string, query: CompanySearchQuery): Promise; }; @@ -619,6 +627,18 @@ function shouldImplicitlyMoveCommentedIssueToTodo(input: { return true; } +function shouldHumanCommentResumeInProgressScheduledRetry(input: { + hasComment: boolean; + issueStatus: string | null | undefined; + assigneeAgentId: string | null | undefined; + actorType: "agent" | "user"; +}) { + if (!input.hasComment) return false; + if (input.actorType !== "user") return false; + if (input.issueStatus !== "in_progress") return false; + return typeof input.assigneeAgentId === "string" && input.assigneeAgentId.length > 0; +} + function isExplicitResumeCapableStatus(status: string | null | undefined) { return status === "done" || status === "blocked" || status === "todo" || status === "in_progress"; } @@ -636,6 +656,8 @@ function queueResolvedInteractionContinuationWakeup(input: { }; actor: { actorType: "user" | "agent"; actorId: string }; source: string; + forceFreshSession?: boolean; + workspaceRefreshReason?: string | null; }) { if ( input.interaction.continuationPolicy !== "wake_assignee" @@ -648,6 +670,8 @@ function queueResolvedInteractionContinuationWakeup(input: { if (input.interaction.status === "expired") return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; + const forceFreshSession = input.forceFreshSession === true; + const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason); void input.heartbeat.wakeup(input.issue.assigneeAgentId, { source: "automation", triggerDetail: "system", @@ -673,6 +697,8 @@ function queueResolvedInteractionContinuationWakeup(input: { sourceRunId: input.interaction.sourceRunId ?? null, wakeReason: "issue_commented", source: input.source, + ...(forceFreshSession ? { forceFreshSession: true } : {}), + ...(workspaceRefreshReason ? { workspaceRefreshReason } : {}), }, }).catch((err) => logger.warn({ err, @@ -843,6 +869,7 @@ export function issueRoutes( const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const issueReferencesSvc = issueReferenceService(db); + const issueThreadInteractionsSvc = issueThreadInteractionService(db); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); @@ -857,6 +884,217 @@ export function issueRoutes( }; const feedbackExportService = opts?.feedbackExportService; const environmentsSvc = environmentService(db); + + async function cancelScheduledRetrySupersededByComment(input: { + scheduledRetryRunId: string | null | undefined; + issue: { id: string; companyId: string }; + actor: ReturnType; + }) { + const scheduledRetryRunId = readNonEmptyString(input.scheduledRetryRunId); + if (!scheduledRetryRunId) return null; + + try { + const cancelled = await heartbeat.cancelRun(scheduledRetryRunId); + const cancelledRunId = cancelled?.id ?? scheduledRetryRunId; + await logActivity(db, { + companyId: input.issue.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelledRunId, + details: { + source: "issue_comment_scheduled_retry_superseded", + issueId: input.issue.id, + }, + }); + return cancelledRunId; + } catch (err) { + logger.error( + { err, issueId: input.issue.id, runId: scheduledRetryRunId }, + "failed to cancel scheduled retry superseded by issue comment", + ); + throw err; + } + } + + async function classifySourceRecoveryRevalidation(input: { + issue: IssueRouteSnapshot; + trigger: RecoveryRevalidationTrigger; + statusChanged?: boolean; + assigneeChanged?: boolean; + blockersChanged?: boolean; + executionPolicyChanged?: boolean; + monitorChanged?: boolean; + documentChanged?: boolean; + workProductChanged?: boolean; + resumeRequested?: boolean; + reopened?: boolean; + blockedToTodoRecovery?: boolean; + }): Promise { + const { issue } = input; + if (issue.status === "done" || issue.status === "cancelled") { + return `Recovery action became stale because the source issue reached ${issue.status}.`; + } + if (input.blockedToTodoRecovery === true) { + return "Recovery action became stale because the source issue was manually moved from blocked to todo."; + } + + if (input.trigger === "read_projection") return null; + if ( + input.trigger === "comment" && + input.resumeRequested !== true && + input.reopened !== true && + input.statusChanged !== true + ) { + return null; + } + + const durableSourceChange = + input.statusChanged === true || + input.assigneeChanged === true || + input.blockersChanged === true || + input.executionPolicyChanged === true || + input.monitorChanged === true || + input.documentChanged === true || + input.workProductChanged === true || + input.resumeRequested === true || + input.reopened === true; + if (!durableSourceChange) return null; + + if (issue.status === "blocked") { + const readiness = await svc.getDependencyReadiness(issue.id); + if (readiness.unresolvedBlockerCount > 0) { + return "Recovery action became stale because the source issue now has unresolved first-class blockers."; + } + return null; + } + + if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") { + return "Recovery action became stale because the source issue now has a human owner."; + } + + if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) { + return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`; + } + + if (issue.status === "in_review") { + const executionState = parseIssueExecutionState(issue.executionState); + const participant = executionState?.status === "pending" ? executionState.currentParticipant : null; + if ( + (participant?.type === "agent" && readNonEmptyString(participant.agentId)) || + (participant?.type === "user" && readNonEmptyString(participant.userId)) + ) { + return "Recovery action became stale because the source issue now has a typed review participant."; + } + + const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id); + if (interactions.some((interaction) => interaction.status === "pending")) { + return "Recovery action became stale because the source issue now has a pending issue interaction."; + } + + const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id); + if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) { + return "Recovery action became stale because the source issue now has a pending approval."; + } + } + + const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)); + if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) { + return "Recovery action became stale because the source issue now has a scheduled monitor."; + } + + return null; + } + + async function revalidateActiveSourceRecovery(input: { + issue: IssueRouteSnapshot; + trigger: RecoveryRevalidationTrigger; + actor?: ReturnType | null; + activeRecoveryAction?: Awaited> | null; + statusChanged?: boolean; + assigneeChanged?: boolean; + blockersChanged?: boolean; + executionPolicyChanged?: boolean; + monitorChanged?: boolean; + documentChanged?: boolean; + workProductChanged?: boolean; + resumeRequested?: boolean; + reopened?: boolean; + blockedToTodoRecovery?: boolean; + }) { + const activeRecoveryAction = + input.activeRecoveryAction === undefined + ? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id) + : input.activeRecoveryAction; + if (!activeRecoveryAction) return null; + + const resolutionNote = await classifySourceRecoveryRevalidation(input); + if (!resolutionNote) return activeRecoveryAction; + + const resolved = await recoveryActionsSvc.resolveActiveForIssue({ + companyId: input.issue.companyId, + sourceIssueId: input.issue.id, + actionId: activeRecoveryAction.id, + status: "cancelled", + outcome: "cancelled", + resolutionNote, + }); + if (!resolved) return activeRecoveryAction; + + const actor = input.actor; + await logActivity(db, { + companyId: input.issue.companyId, + actorType: actor?.actorType ?? "system", + actorId: actor?.actorId ?? "system", + agentId: actor?.agentId ?? null, + runId: actor?.runId ?? null, + action: "issue.recovery_action_resolved", + entityType: "issue", + entityId: input.issue.id, + details: { + identifier: input.issue.identifier, + recoveryActionId: resolved.id, + recoveryActionStatus: resolved.status, + outcome: resolved.outcome, + sourceIssueStatus: input.issue.status, + resolutionNote: resolved.resolutionNote, + source: "source_revalidation", + trigger: input.trigger, + }, + }); + + return null; + } + + async function revalidateActiveSourceRecoveryForRead(input: Parameters[0]) { + try { + return await revalidateActiveSourceRecovery(input); + } catch (err) { + logger.warn( + { err, issueId: input.issue.id, trigger: input.trigger }, + "failed to revalidate recovery action during read projection", + ); + return input.activeRecoveryAction ?? null; + } + } + + async function revalidateActiveSourceRecoveryAfterCommittedWrite( + input: Parameters[0], + ) { + try { + return await revalidateActiveSourceRecovery(input); + } catch (err) { + logger.warn( + { err, issueId: input.issue.id, trigger: input.trigger }, + "failed to revalidate recovery action after committed issue write", + ); + return input.activeRecoveryAction ?? null; + } + } + function withContentPath(attachment: T) { return { ...attachment, @@ -1008,29 +1246,48 @@ export function issueRoutes( return (req.actor.companyIds ?? []).includes(companyId); } - function canCreateAgentsLegacy(agent: { permissions: Record | null | undefined; role: string }) { - if (agent.role === "ceo") return true; - if (!agent.permissions || typeof agent.permissions !== "object") return false; - return Boolean((agent.permissions as Record).canCreateAgents); + type TaskAssignmentAuthorizationScope = { + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + }; + + async function resolveAssignmentProjectId(input: { + companyId: string; + projectId: string | null | undefined; + parentIssueId?: string | null; + }) { + if (input.projectId !== undefined) return input.projectId; + if (!input.parentIssueId) return null; + const parent = await svc.getById(input.parentIssueId); + if (!parent || parent.companyId !== input.companyId) return null; + return parent.projectId ?? null; } - async function assertCanAssignTasks(req: Request, companyId: string) { + async function assertCanAssignTasks( + req: Request, + companyId: string, + assignmentScope?: TaskAssignmentAuthorizationScope, + ) { assertCompanyAccess(req, companyId); - if (req.actor.type === "board") { - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); - if (!allowed) throw forbidden("Missing permission: tasks:assign"); - return; - } - if (req.actor.type === "agent") { - if (!req.actor.agentId) throw forbidden("Agent authentication required"); - const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign"); - if (allowedByGrant) return; - const actorAgent = await agentsSvc.getById(req.actor.agentId); - if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return; - throw forbidden("Missing permission: tasks:assign"); - } - throw unauthorized(); + const decision = await access.decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + issueId: assignmentScope?.issueId ?? null, + projectId: assignmentScope?.projectId ?? null, + parentIssueId: assignmentScope?.parentIssueId ?? null, + assigneeAgentId: assignmentScope?.assigneeAgentId ?? null, + assigneeUserId: assignmentScope?.assigneeUserId ?? null, + }, + scope: assignmentScope ?? null, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); } function requireAgentRunId(req: Request, res: Response) { @@ -1046,31 +1303,12 @@ export function issueRoutes( companyId: string, assigneeAgentId: string, ) { - const allowedByGrant = await access.hasPermission( - companyId, - "agent", - actorAgentId, - "tasks:manage_active_checkouts", - ); - if (allowedByGrant) return true; - - const companyAgents = await agentsSvc.list(companyId); - const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent])); - const actorAgent = agentsById.get(actorAgentId); - if (!actorAgent) return false; - if (canCreateAgentsLegacy(actorAgent)) return true; - - // Reporting-chain managers may intervene in an agent's active checkout - // without taking the task over. Peers must own the checkout/run first. - let cursor: string | null = assigneeAgentId; - for (let depth = 0; cursor && depth < 50; depth += 1) { - const assignee = agentsById.get(cursor); - if (!assignee) return false; - if (assignee.reportsTo === actorAgentId) return true; - cursor = assignee.reportsTo; - } - - return false; + const decision = await access.decide({ + actor: { type: "agent", agentId: actorAgentId, companyId }, + action: "tasks:manage_active_checkouts", + resource: { type: "issue", companyId, assigneeAgentId }, + }); + return decision.allowed; } async function assertAgentIssueMutationAllowed( @@ -1141,6 +1379,87 @@ export function issueRoutes( return true; } + function isStatusOnlyCheapRecoveryContext(contextSnapshot: unknown) { + if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return false; + const context = contextSnapshot as Record; + return context.modelProfile === "cheap" && + context.recoveryIntent === "status_only" && + context.allowDeliverableWork === false && + context.allowDocumentUpdates === false && + context.resumeRequiresNormalModel === true; + } + + function requestsCheapIssueAssigneeModelProfile(input: { assigneeAdapterOverrides?: unknown }) { + const overrides = input.assigneeAdapterOverrides; + return !!overrides && + typeof overrides === "object" && + !Array.isArray(overrides) && + (overrides as Record).modelProfile === "cheap"; + } + + async function loadActorRunContext(req: Request, companyId: string) { + if (req.actor.type !== "agent") return null; + const runId = req.actor.runId?.trim(); + if (!runId) return null; + const run = await db + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + if (!run || run.companyId !== companyId || run.agentId !== req.actor.agentId) return null; + return run; + } + + async function assertCheapRecoveryIssueAssigneeProfileAllowed( + req: Request, + res: Response, + issue: { id?: string; companyId: string }, + input: { assigneeAdapterOverrides?: unknown }, + ) { + if (!requestsCheapIssueAssigneeModelProfile(input)) return true; + const run = await loadActorRunContext(req, issue.companyId); + if (!run || !isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true; + + res.status(403).json({ + error: "Cheap status-only recovery runs cannot assign downstream issue work to the cheap model profile", + details: { + issueId: issue.id ?? null, + runId: run.id, + modelProfile: "cheap", + recoveryIntent: "status_only", + resumeRequiresNormalModel: true, + }, + }); + return false; + } + + async function assertDeliverableMutationAllowedByRunContext( + req: Request, + res: Response, + issue: { id: string; companyId: string }, + ) { + const run = await loadActorRunContext(req, issue.companyId); + if (!run) return true; + if (!isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true; + + res.status(403).json({ + error: "Cheap status-only recovery runs cannot update issue documents, plans, or deliverable artifacts", + details: { + issueId: issue.id, + runId: run.id, + modelProfile: "cheap", + recoveryIntent: "status_only", + resumeRequiresNormalModel: true, + }, + }); + return false; + } + function assertStructuredCommentFieldsAllowed( req: Request, res: Response, @@ -1240,6 +1559,51 @@ export function issueRoutes( return false; } + async function assertRecoveryActionAuthority( + req: Request, + res: Response, + issue: { id: string; companyId: string; assigneeAgentId: string | null }, + activeRecoveryAction: Awaited>, + input: { source: "issue_update" | "recovery_action_resolution" }, + ) { + if (req.actor.type !== "agent") return true; + if (!activeRecoveryAction) return true; + + const actorAgentId = req.actor.agentId; + if (!actorAgentId) { + res.status(403).json({ error: "Agent authentication required" }); + return false; + } + if (issue.assigneeAgentId === actorAgentId) return true; + if ( + issue.assigneeAgentId && + await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId) + ) { + return true; + } + if (activeRecoveryAction.ownerAgentId === actorAgentId) return true; + if ( + activeRecoveryAction.ownerAgentId && + await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId) + ) { + return true; + } + + res.status(403).json({ + error: "Agent cannot resolve another owner's recovery action", + details: { + issueId: issue.id, + recoveryActionId: activeRecoveryAction.id, + actorAgentId, + assigneeAgentId: issue.assigneeAgentId, + recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId, + source: input.source, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"], + }, + }); + return false; + } + async function resolveActiveIssueRun(issue: { id: string; assigneeAgentId: string | null; @@ -1445,6 +1809,8 @@ export function issueRoutes( ? Number.parseInt(rawOffset, 10) : null; const attention = req.query.attention as string | undefined; + const sortField = req.query.sortField as string | undefined; + const sortDir = req.query.sortDir as string | undefined; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -1474,6 +1840,14 @@ export function issueRoutes( res.status(400).json({ error: "offset must be a non-negative integer" }); return; } + if (sortField !== undefined && sortField !== "updated") { + res.status(400).json({ error: "sortField must be 'updated' when provided" }); + return; + } + if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") { + res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" }); + return; + } const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { @@ -1506,12 +1880,27 @@ export function issueRoutes( q: req.query.q as string | undefined, limit, offset, + sortField: sortField === "updated" ? "updated" : undefined, + sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined, }); const issueIds = result.map((issue) => issue.id); const [handoffStates, recoveryActionByIssue] = await Promise.all([ listSuccessfulRunHandoffStates(db, companyId, issueIds), recoveryActionsSvc.listActiveForIssues(companyId, issueIds), ]); + const actor = getActorInfo(req); + await Promise.all(result.map(async (issue) => { + const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null; + if (!activeRecoveryAction) return; + const revalidated = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor, + activeRecoveryAction, + }); + if (revalidated) recoveryActionByIssue.set(issue.id, revalidated); + else recoveryActionByIssue.delete(issue.id); + })); res.json(result.map((issue) => ({ ...issue, successfulRunHandoff: handoffStates.get(issue.id) ?? null, @@ -1668,6 +2057,12 @@ export function issueRoutes( relations, recoveryActionsByRelationIssue, ); + const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + activeRecoveryAction, + }); res.json({ issue: { @@ -1680,7 +2075,7 @@ export function issueRoutes( ...(blockerAttention ? { blockerAttention } : {}), productivityReview, scheduledRetry, - activeRecoveryAction, + activeRecoveryAction: revalidatedActiveRecoveryAction, priority: issue.priority, projectId: issue.projectId, goalId: goal?.id ?? issue.goalId, @@ -1786,6 +2181,12 @@ export function issueRoutes( relations, recoveryActionsByRelationIssue, ); + const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + activeRecoveryAction, + }); const mentionedProjects = mentionedProjectIds.length > 0 ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) : []; @@ -1801,7 +2202,7 @@ export function issueRoutes( productivityReview, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, scheduledRetry, - activeRecoveryAction, + activeRecoveryAction: revalidatedActiveRecoveryAction, blockedBy: relationsWithRecoveryActions.blockedBy, blocks: relationsWithRecoveryActions.blocks, relatedWork: referenceSummary, @@ -1823,7 +2224,11 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id); + const active = await revalidateActiveSourceRecoveryForRead({ + issue, + trigger: "read_projection", + actor: getActorInfo(req), + }); res.json({ active, actions: active ? [active] : [], @@ -1839,6 +2244,18 @@ export function issueRoutes( } assertCompanyAccess(req, existing.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; + const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id); + if ( + !(await assertRecoveryActionAuthority( + req, + res, + existing, + activeRecoveryAction, + { source: "recovery_action_resolution" }, + )) + ) { + return; + } const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body; if (outcome === "false_positive" || outcome === "cancelled") { @@ -1948,6 +2365,36 @@ export function issueRoutes( }, }); + if ( + sourceIssueStatus === "todo" && + existing.status !== result.issue.status && + result.issue.assigneeAgentId + ) { + void heartbeat.wakeup(result.issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_recovery_action_restored", + payload: { + issueId: result.issue.id, + recoveryActionId: result.recoveryAction.id, + mutation: "recovery_action_resolution", + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: result.issue.id, + taskId: result.issue.id, + wakeReason: "issue_recovery_action_restored", + source: "issue.recovery_action_resolution", + recoveryActionId: result.recoveryAction.id, + }, + }).catch((err) => + logger.warn( + { err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId }, + "failed to wake agent after recovery action restored issue", + )); + } + res.json({ issue: { ...result.issue, @@ -2013,6 +2460,7 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -2032,8 +2480,11 @@ export function issueRoutes( createdByAgentId: actor.agentId ?? null, createdByUserId: actor.actorType === "user" ? actor.actorId : null, createdByRunId: actor.runId ?? null, + lockedDocumentStrategy: req.actor.type === "agent" ? "create_new_document" : "conflict", }); const doc = result.document; + const redirectedFromLockedDocument = + "redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null; await issueReferencesSvc.syncDocument(doc.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); @@ -2053,6 +2504,7 @@ export function issueRoutes( title: doc.title, format: doc.format, revisionNumber: doc.latestRevisionNumber, + redirectedFromLockedDocument, ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity), @@ -2083,9 +2535,106 @@ export function issueRoutes( }); } + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); + res.status(result.created ? 201 : 200).json(doc); }); + router.post("/issues/:id/documents/:key/lock", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const actor = getActorInfo(req); + const result = await documentsSvc.lockIssueDocument({ + issueId: issue.id, + key: keyParsed.data, + lockedByAgentId: actor.agentId ?? null, + lockedByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + + if (result.changed) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_locked", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + title: result.document.title, + lockedAt: result.document.lockedAt, + }, + }); + } + + res.json(result.document); + }); + + router.post("/issues/:id/documents/:key/unlock", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const actor = getActorInfo(req); + const result = await documentsSvc.unlockIssueDocument(issue.id, keyParsed.data); + + if (result.changed) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_unlocked", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + title: result.document.title, + }, + }); + } + + res.json(result.document); + }); + router.get("/issues/:id/documents/:key/revisions", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -2116,6 +2665,7 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -2180,6 +2730,13 @@ export function issueRoutes( source: "issue.document_restored", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); + res.json(result.document); }, ); @@ -2250,6 +2807,12 @@ export function issueRoutes( actor, source: "issue.document_deleted", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); res.json({ ok: true }); }); @@ -2262,6 +2825,7 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { ...req.body, projectId: req.body.projectId ?? issue.projectId ?? null, @@ -2282,6 +2846,12 @@ export function issueRoutes( entityId: issue.id, details: { workProductId: product.id, type: product.type, provider: product.provider }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.status(201).json(product); }); @@ -2299,6 +2869,7 @@ export function issueRoutes( return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const product = await workProductsSvc.update(id, req.body); if (!product) { res.status(404).json({ error: "Work product not found" }); @@ -2316,6 +2887,12 @@ export function issueRoutes( entityId: existing.issueId, details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.json(product); }); @@ -2333,6 +2910,7 @@ export function issueRoutes( return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const removed = await workProductsSvc.remove(id); if (!removed) { res.status(404).json({ error: "Work product not found" }); @@ -2350,6 +2928,12 @@ export function issueRoutes( entityId: existing.issueId, details: { workProductId: removed.id, type: removed.type }, }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "work_product", + actor, + workProductChanged: true, + }); res.json(removed); }); @@ -2560,8 +3144,18 @@ export function issueRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, companyId); + await assertCanAssignTasks(req, companyId, { + projectId: await resolveAssignmentProjectId({ + companyId, + projectId: req.body.projectId, + parentIssueId: req.body.parentId, + }), + parentIssueId: req.body.parentId ?? null, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -2655,8 +3249,14 @@ export function issueRoutes( } assertCompanyAccess(req, parent.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); + if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, parent.companyId); + await assertCanAssignTasks(req, parent.companyId, { + projectId: req.body.projectId ?? parent.projectId ?? null, + parentIssueId: parent.id, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -2801,6 +3401,7 @@ export function issueRoutes( assertCompanyAccess(req, existing.companyId); assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; + if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return; const actor = getActorInfo(req); const isClosed = isClosedIssueStatus(existing.status); @@ -2837,6 +3438,40 @@ export function issueRoutes( const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; + const recoveryRelevantSourceMutationRequested = + req.body.status !== undefined || + normalizedAssigneeAgentId !== undefined || + req.body.assigneeUserId !== undefined || + Array.isArray(req.body.blockedByIssueIds) || + req.body.executionPolicy !== undefined || + explicitMoveToTodoRequested; + const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested + ? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id) + : null; + if ( + recoveryRelevantSourceMutationRequested && + !(await assertRecoveryActionAuthority( + req, + res, + existing, + activeRecoveryActionBeforeUpdate, + { source: "issue_update" }, + )) + ) { + return; + } + const scheduledRetryForHumanComment = + shouldHumanCommentResumeInProgressScheduledRetry({ + hasComment: !!commentBody, + issueStatus: existing.status, + assigneeAgentId: requestedAssigneeAgentId, + actorType: actor.actorType, + }) + ? await svc.getCurrentScheduledRetry(existing.id) + : null; + const shouldResumeInProgressScheduledRetry = + !!scheduledRetryForHumanComment && + scheduledRetryForHumanComment.agentId === requestedAssigneeAgentId; const effectiveMoveToTodoRequested = explicitMoveToTodoRequested || (!!commentBody && @@ -2845,7 +3480,8 @@ export function issueRoutes( assigneeAgentId: requestedAssigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, - })); + })) || + shouldResumeInProgressScheduledRetry; const updateReferenceSummaryBefore = titleOrDescriptionChanged ? await issueReferencesSvc.listIssueReferenceSummary(existing.id) : null; @@ -2907,11 +3543,23 @@ export function issueRoutes( if ( commentBody && effectiveMoveToTodoRequested && - (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) && + (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry) && updateFields.status === undefined ) { updateFields.status = "todo"; } + let cancelledScheduledRetryRunId: string | null = null; + if ( + commentBody && + shouldResumeInProgressScheduledRetry && + updateFields.status === "todo" + ) { + cancelledScheduledRetryRunId = await cancelScheduledRetrySupersededByComment({ + scheduledRetryRunId: scheduledRetryForHumanComment?.runId, + issue: existing, + actor, + }); + } if (req.body.executionPolicy !== undefined) { updateFields.executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(req.body.executionPolicy), @@ -2997,7 +3645,23 @@ export function issueRoutes( if (assigneeWillChange && !transition.workflowControlledAssignment) { if (!isAgentReturningIssueToCreator) { - await assertCanAssignTasks(req, existing.companyId); + await assertCanAssignTasks(req, existing.companyId, { + issueId: existing.id, + projectId: await resolveAssignmentProjectId({ + companyId: existing.companyId, + projectId: updateFields.projectId === undefined + ? existing.projectId + : updateFields.projectId as string | null | undefined, + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + }), + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + assigneeAgentId: nextAssigneeAgentId, + assigneeUserId: nextAssigneeUserId, + }); } } @@ -3113,6 +3777,7 @@ export function issueRoutes( let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown; + activeRecoveryAction?: unknown; relatedWork?: Awaited>; referencedIssueIdentifiers?: string[]; } = issue; @@ -3164,6 +3829,37 @@ export function issueRoutes( previous.status !== undefined && issue.status === "todo"; const reopenFromStatus = reopened ? existing.status : null; + const scheduledRetrySupersededByComment = + shouldResumeInProgressScheduledRetry && + previous.status !== undefined && + existing.status === "in_progress" && + issue.status === "todo"; + const statusChangedFromBlockedToTodo = + existing.status === "blocked" && + issue.status === "todo" && + (req.body.status !== undefined || reopened); + const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "issue_update", + actor, + activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined, + statusChanged: existing.status !== issue.status, + assigneeChanged: + existing.assigneeAgentId !== issue.assigneeAgentId || + existing.assigneeUserId !== issue.assigneeUserId, + blockersChanged: Array.isArray(req.body.blockedByIssueIds), + executionPolicyChanged: req.body.executionPolicy !== undefined, + monitorChanged, + resumeRequested: resumeRequested === true, + reopened, + blockedToTodoRecovery: statusChangedFromBlockedToTodo, + }); + if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) { + issueResponse = { + ...issueResponse, + activeRecoveryAction: null, + }; + } await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -3179,6 +3875,13 @@ export function issueRoutes( ...(commentBody ? { source: "comment" } : {}), ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(scheduledRetrySupersededByComment + ? { + scheduledRetrySupersededByComment: true, + scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null, + ...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}), + } + : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), ...(workspaceChange ? { workspaceChange } : {}), @@ -3396,6 +4099,13 @@ export function issueRoutes( issueTitle: issue.title, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(scheduledRetrySupersededByComment + ? { + scheduledRetrySupersededByComment: true, + scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null, + ...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}), + } + : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), ...summarizeIssueReferenceActivityDetails({ @@ -3437,10 +4147,6 @@ export function issueRoutes( existing.status === "backlog" && issue.status !== "backlog" && req.body.status !== undefined; - const statusChangedFromBlockedToTodo = - existing.status === "blocked" && - issue.status === "todo" && - (req.body.status !== undefined || reopened); const statusChangedFromClosedToTodo = isClosedIssueStatus(existing.status) && issue.status === "todo" && @@ -3725,6 +4431,16 @@ export function issueRoutes( return; } + if (issue.assigneeAgentId !== req.body.agentId) { + await assertCanAssignTasks(req, issue.companyId, { + issueId: issue.id, + projectId: issue.projectId ?? null, + parentIssueId: issue.parentId ?? null, + assigneeAgentId: req.body.agentId, + assigneeUserId: null, + }); + } + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); @@ -3897,7 +4613,17 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const interactions = await issueThreadInteractionService(db).listForIssue(id); + const actor = getActorInfo(req); + const interactionSvc = issueThreadInteractionService(db); + const expiredInteractions = await interactionSvc.expireRequestConfirmationsSupersededByHistoricalComments(issue); + await logExpiredRequestConfirmations({ + issue, + interactions: expiredInteractions, + actor, + source: "issue.interactions.catchup_superseded_by_comment", + }); + + const interactions = await interactionSvc.listForIssue(id); res.json(interactions); }); @@ -4032,12 +4758,18 @@ export function issueRoutes( }); } + const acceptedPlanConfirmation = + interaction.kind === "request_confirmation" && + interaction.status === "accepted" && + issue.workMode === "planning"; queueResolvedInteractionContinuationWakeup({ heartbeat, issue: continuationWakeIssue, interaction, actor, source: "issue.interaction.accept", + forceFreshSession: acceptedPlanConfirmation, + workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null, }); res.json(interaction); @@ -4397,6 +5129,18 @@ export function issueRoutes( const isClosed = isClosedIssueStatus(issue.status); const isBlocked = issue.status === "blocked"; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; + const scheduledRetryForHumanComment = + shouldHumanCommentResumeInProgressScheduledRetry({ + hasComment: true, + issueStatus: issue.status, + assigneeAgentId: issue.assigneeAgentId, + actorType: actor.actorType, + }) + ? await svc.getCurrentScheduledRetry(issue.id) + : null; + const shouldResumeInProgressScheduledRetry = + !!scheduledRetryForHumanComment && + scheduledRetryForHumanComment.agentId === issue.assigneeAgentId; const effectiveMoveToTodoRequested = explicitMoveToTodoRequested || shouldImplicitlyMoveCommentedIssueToTodo({ @@ -4404,7 +5148,8 @@ export function issueRoutes( assigneeAgentId: issue.assigneeAgentId, actorType: actor.actorType, actorId: actor.actorId, - }); + }) || + shouldResumeInProgressScheduledRetry; const hasUnresolvedFirstClassBlockers = isBlocked && effectiveMoveToTodoRequested ? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0 @@ -4419,14 +5164,27 @@ export function issueRoutes( let currentIssue = issue; const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id); - if (effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers))) { + let scheduledRetrySupersededByComment = false; + let cancelledScheduledRetryRunId: string | null = null; + if ( + effectiveMoveToTodoRequested && + (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers) || shouldResumeInProgressScheduledRetry) + ) { + scheduledRetrySupersededByComment = shouldResumeInProgressScheduledRetry && issue.status === "in_progress"; + cancelledScheduledRetryRunId = scheduledRetrySupersededByComment + ? await cancelScheduledRetrySupersededByComment({ + scheduledRetryRunId: scheduledRetryForHumanComment?.runId, + issue, + actor, + }) + : null; const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); return; } - reopened = true; - reopenFromStatus = issue.status; + reopened = isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers); + reopenFromStatus = reopened ? issue.status : null; currentIssue = reopenedIssue; await logActivity(db, { @@ -4440,8 +5198,14 @@ export function issueRoutes( entityId: currentIssue.id, details: { status: "todo", - reopened: true, - reopenedFrom: reopenFromStatus, + ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(scheduledRetrySupersededByComment + ? { + scheduledRetrySupersededByComment: true, + scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null, + ...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}), + } + : {}), source: "comment", ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), identifier: currentIssue.identifier, @@ -4512,6 +5276,13 @@ export function issueRoutes( issueTitle: currentIssue.title, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(scheduledRetrySupersededByComment + ? { + scheduledRetrySupersededByComment: true, + scheduledRetryRunId: scheduledRetryForHumanComment?.runId ?? null, + ...(cancelledScheduledRetryRunId ? { cancelledScheduledRetryRunId } : {}), + } + : {}), ...(interruptedRunId ? { interruptedRunId } : {}), ...summarizeIssueReferenceActivityDetails({ addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity), @@ -4536,6 +5307,16 @@ export function issueRoutes( source: "issue.comment", }); + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue: currentIssue, + trigger: "comment", + actor, + statusChanged: reopened || scheduledRetrySupersededByComment, + resumeRequested: resumeRequested === true, + reopened, + blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo", + }); + // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); @@ -4762,6 +5543,7 @@ export function issueRoutes( return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; const company = await companiesSvc.getById(companyId); const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes); @@ -4881,6 +5663,7 @@ export function issueRoutes( return; } if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; try { await storage.deleteObject(attachment.companyId, attachment.objectKey); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 44368518..d572c90d 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -55,7 +55,7 @@ import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; -import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import type { PluginPerformActionActorContext, ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { assertAuthenticated, @@ -120,7 +120,7 @@ interface AvailablePluginExample { displayName: string; description: string; localPath: string; - tag: "example"; + tag: "example" | "first-party"; } /** Response body for GET /api/plugins/:pluginId/health */ @@ -152,6 +152,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../../.."); const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ + { + packageName: "@paperclipai/plugin-workspace-diff", + pluginKey: "paperclip.workspace-diff", + displayName: "Workspace Changes", + description: "First-party workspace Changes tab backed by plugin-local Git diff computation.", + localPath: "packages/plugins/plugin-workspace-diff", + tag: "first-party", + }, { packageName: "@paperclipai/plugin-hello-world-example", pluginKey: "paperclip.hello-world-example", @@ -564,6 +572,43 @@ export function pluginRoutes( return companyId; } + function performActionActorContext(req: Request, companyId: string | undefined): PluginPerformActionActorContext { + const scopedCompanyId = companyId ?? null; + if (req.actor.type === "agent") { + return { + type: "agent", + userId: null, + agentId: req.actor.agentId ?? null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + if (req.actor.type === "board") { + return { + type: "user", + userId: req.actor.userId ?? null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + return { + type: "system", + userId: null, + agentId: null, + runId: req.actor.runId ?? null, + companyId: scopedCompanyId, + }; + } + + function actionParamsWithAuthorizedCompanyScope( + params: Record | undefined, + companyId: string | undefined, + ): Record { + const base = params ?? {}; + return companyId === undefined ? base : { ...base, companyId }; + } + async function validateToolRunContextScope(runContext: ToolRunContext): Promise { const [agent] = await db .select({ companyId: agents.companyId }) @@ -976,6 +1021,12 @@ export function pluginRoutes( message: err.message, details: err.data, }; + case PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED: + return { + code: "INVOCATION_SCOPE_DENIED", + message: err.message, + details: err.data, + }; case PLUGIN_RPC_ERROR_CODES.TIMEOUT: return { code: "TIMEOUT", @@ -1013,6 +1064,34 @@ export function pluginRoutes( }; } + function attachPluginBridgeErrorContext( + req: Request, + res: Response, + err: unknown, + bridgeError: PluginBridgeErrorResponse, + metadata: Record, + ): void { + const rootError = err instanceof Error ? err : new Error(String(err)); + (res as any).__errorContext = { + error: { + message: bridgeError.message, + stack: rootError.stack, + name: rootError.name, + details: { + ...metadata, + bridgeCode: bridgeError.code, + bridgeDetails: bridgeError.details, + }, + }, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + }; + (res as any).err = rootError; + } + /** * POST /api/plugins/:pluginId/bridge/data * @@ -1064,6 +1143,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + }); res.status(502).json(bridgeError); return; } @@ -1075,7 +1159,7 @@ export function pluginRoutes( return; } - assertPluginBridgeScope(req, body.companyId); + const companyId = assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1083,6 +1167,7 @@ export function pluginRoutes( "getData", { key: body.key, + ...(companyId ? { companyId } : {}), params: body.params ?? {}, renderEnvironment: body.renderEnvironment ?? null, }, @@ -1090,6 +1175,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1123,7 +1214,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1145,6 +1236,11 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + }); res.status(502).json(bridgeError); return; } @@ -1156,7 +1252,7 @@ export function pluginRoutes( return; } - assertPluginBridgeScope(req, body.companyId); + const companyId = assertPluginBridgeScope(req, body.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1164,13 +1260,20 @@ export function pluginRoutes( "performAction", { key: body.key, - params: body.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: body.key, + }); res.status(502).json(bridgeError); } }); @@ -1227,6 +1330,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1237,7 +1346,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - assertPluginBridgeScope(req, body?.companyId); + const companyId = assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1245,6 +1354,7 @@ export function pluginRoutes( "getData", { key, + ...(companyId ? { companyId } : {}), params: body?.params ?? {}, renderEnvironment: body?.renderEnvironment ?? null, }, @@ -1252,6 +1362,12 @@ export function pluginRoutes( res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "getData", + dataKey: key, + }); res.status(502).json(bridgeError); } }); @@ -1282,7 +1398,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoardOrgAccess(req); + assertAuthenticated(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1304,6 +1420,12 @@ export function pluginRoutes( code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; + attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); return; } @@ -1314,7 +1436,7 @@ export function pluginRoutes( renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; - assertPluginBridgeScope(req, body?.companyId); + const companyId = assertPluginBridgeScope(req, body?.companyId); try { const result = await bridgeDeps.workerManager.call( @@ -1322,13 +1444,20 @@ export function pluginRoutes( "performAction", { key, - params: body?.params ?? {}, + params: actionParamsWithAuthorizedCompanyScope(body?.params, companyId), + actorContext: performActionActorContext(req, companyId), renderEnvironment: body?.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); + attachPluginBridgeErrorContext(req, res, err, bridgeError, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + bridgeMethod: "performAction", + actionKey: key, + }); res.status(502).json(bridgeError); } }); @@ -1520,7 +1649,10 @@ export function pluginRoutes( } catch (err) { const status = typeof (err as { status?: unknown }).status === "number" ? (err as { status: number }).status - : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED + : err instanceof JsonRpcCallError && ( + err.code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED || + err.code === PLUGIN_RPC_ERROR_CODES.INVOCATION_SCOPE_DENIED + ) ? 403 : err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED ? 501 diff --git a/server/src/routes/resource-memberships.ts b/server/src/routes/resource-memberships.ts new file mode 100644 index 00000000..38f1e7f7 --- /dev/null +++ b/server/src/routes/resource-memberships.ts @@ -0,0 +1,120 @@ +import { Router, type Request, type Response } from "express"; +import type { Db } from "@paperclipai/db"; +import { updateResourceMembershipSchema } from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { getActorInfo } from "./authz.js"; +import { logActivity, resourceMembershipService } from "../services/index.js"; + +function requireBoardUserId(req: Request, res: Response): string | null { + if (req.actor.type !== "board" || !req.actor.userId) { + res.status(403).json({ error: "Board user access required" }); + return null; + } + return req.actor.userId; +} + +async function logMembershipChange( + db: Db, + req: Request, + input: { + companyId: string; + userId: string; + resourceType: "project" | "agent"; + resourceId: string; + state: "joined" | "left"; + policySource: string; + }, +) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId: input.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: `resource_membership.${input.state}`, + entityType: input.resourceType, + entityId: input.resourceId, + details: { + userId: input.userId, + resourceType: input.resourceType, + resourceId: input.resourceId, + state: input.state, + policySource: input.policySource, + }, + }); +} + +export function resourceMembershipRoutes(db: Db) { + const router = Router(); + const svc = resourceMembershipService(db); + + router.get("/companies/:companyId/resource-memberships/me", async (req, res) => { + const companyId = req.params.companyId as string; + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.listForUser(companyId, userId, req.actor)); + }); + + router.put( + "/companies/:companyId/resource-memberships/me/projects/:projectId", + validate(updateResourceMembershipSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const projectId = req.params.projectId as string; + const userId = requireBoardUserId(req, res); + if (!userId) return; + const result = await svc.updateProject({ + companyId, + projectId, + userId, + state: req.body.state, + actor: req.actor, + }); + if (result.changed) { + await logMembershipChange(db, req, { + companyId, + userId, + resourceType: "project", + resourceId: projectId, + state: result.state, + policySource: result.policySource, + }); + } + const { changed: _changed, policySource: _policySource, ...response } = result; + res.json(response); + }, + ); + + router.put( + "/companies/:companyId/resource-memberships/me/agents/:agentId", + validate(updateResourceMembershipSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const agentId = req.params.agentId as string; + const userId = requireBoardUserId(req, res); + if (!userId) return; + const result = await svc.updateAgent({ + companyId, + agentId, + userId, + state: req.body.state, + actor: req.actor, + }); + if (result.changed) { + await logMembershipChange(db, req, { + companyId, + userId, + resourceType: "agent", + resourceId: agentId, + state: result.state, + policySource: result.policySource, + }); + } + const { changed: _changed, policySource: _policySource, ...response } = result; + res.json(response); + }, + ); + + return router; +} diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index be9d503f..760d36c7 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -6,6 +6,7 @@ import { remoteSecretImportPreviewSchema, remoteSecretImportSchema, rotateSecretSchema, + secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, updateSecretSchema, } from "@paperclipai/shared"; @@ -41,6 +42,41 @@ export function secretRoutes(db: Db) { res.json(await svc.listProviderConfigs(companyId)); }); + router.post( + "/companies/:companyId/secret-provider-configs/discovery/preview", + validate(secretProviderConfigDiscoveryPreviewSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const preview = await svc.previewProviderConfigDiscovery(companyId, { + provider: req.body.provider, + config: req.body.config, + query: req.body.query, + nextToken: req.body.nextToken, + pageSize: req.body.pageSize, + }); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret_provider_config.discovery_previewed", + entityType: "secret_provider_config_discovery", + entityId: companyId, + details: { + provider: preview.provider, + candidateCount: preview.candidates.length, + sampledSecretCount: preview.sampledSecretCount, + warningCount: preview.warnings.length, + }, + }); + + res.json(preview); + }, + ); + router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; @@ -136,27 +172,27 @@ export function secretRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); - const disabled = await svc.disableProviderConfig(id); - if (!disabled) { + const removed = await svc.removeProviderConfig(id); + if (!removed) { res.status(404).json({ error: "Provider vault not found" }); return; } await logActivity(db, { - companyId: disabled.companyId, + companyId: removed.companyId, actorType: "user", actorId: req.actor.userId ?? "board", - action: "secret_provider_config.disabled", + action: "secret_provider_config.removed", entityType: "secret_provider_config", - entityId: disabled.id, + entityId: removed.id, details: { - provider: disabled.provider, - displayName: disabled.displayName, - status: disabled.status, + provider: removed.provider, + displayName: removed.displayName, + remoteDeleted: false, }, }); - res.json(disabled); + res.json(removed); }); router.post("/secret-provider-configs/:id/default", async (req, res) => { diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index 8c638594..a697556a 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -1,6 +1,6 @@ import { createHash, createHmac } from "node:crypto"; import { S3Client } from "@aws-sdk/client-s3"; -import type { DeploymentMode } from "@paperclipai/shared"; +import type { DeploymentMode, SecretProviderConfigDiscoveryPreviewResult } from "@paperclipai/shared"; import { unprocessable } from "../errors.js"; import type { PreparedSecretVersion, @@ -24,6 +24,8 @@ const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30; const AWS_SECRETS_MANAGER_REQUEST_TIMEOUT_MS = 30_000; const AWS_CREDENTIAL_CACHE_TTL_MS = 5 * 60_000; const AWS_CREDENTIAL_EXPIRATION_SKEW_MS = 60_000; +const PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT = 3; +const PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT = 6; const AWS_RUNTIME_CREDENTIAL_WARNING = "AWS bootstrap credentials must be available to the Paperclip server runtime through the AWS SDK default credential provider chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials."; const AWS_CREDENTIAL_CUSTODY_WARNING = @@ -590,6 +592,234 @@ function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Re }; } +function tagValue(tags: Map, keys: string[]) { + for (const key of keys) { + const value = tags.get(key.toLowerCase()); + if (value) return value; + } + return null; +} + +function normalizeAwsTags(tags: AwsSecretsManagerTag[] | undefined) { + const normalized = new Map(); + for (const tag of tags ?? []) { + const key = tag.Key?.trim(); + const value = tag.Value?.trim(); + if (key && value) normalized.set(key.toLowerCase(), value); + } + return normalized; +} + +function commonValue(values: Array) { + const nonEmpty = values.filter((value): value is string => Boolean(value?.trim())); + if (nonEmpty.length === 0) return null; + const first = nonEmpty[0]; + return nonEmpty.every((value) => value === first) ? first : null; +} + +function uniqueValues(values: Array) { + return [...new Set(values.filter((value): value is string => Boolean(value?.trim())))]; +} + +function pathSegments(name: string) { + return name.split("/").map((segment) => segment.trim()).filter(Boolean); +} + +function inferPathSignals(entry: AwsSecretsManagerListSecretEntry, tags: Map) { + const name = entry.Name?.trim() || entry.ARN?.trim() || ""; + const segments = pathSegments(name); + const paperclipDeploymentId = tagValue(tags, ["paperclip:deployment-id"]); + const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip"; + + if (paperclipDeploymentId || paperclipManaged) { + return { + prefix: segments[0] ?? DEFAULT_PREFIX, + namespace: paperclipDeploymentId ?? segments[1] ?? null, + }; + } + + if (segments.length >= 3) { + return { + prefix: segments[0] ?? null, + namespace: segments[1] ?? null, + }; + } + + return { + prefix: segments[0] ?? null, + namespace: null, + }; +} + +function discoveryDisplayName(input: { + environmentTag: string | null; + ownerTag: string | null; + namespace: string | null; + secretNamePrefix: string | null; +}) { + const qualifier = + input.environmentTag ?? + input.namespace ?? + input.secretNamePrefix ?? + input.ownerTag ?? + "discovered"; + return `AWS ${qualifier}`; +} + +function discoverAwsProviderConfigCandidates(input: { + companyId: string; + config: AwsSecretsManagerConfig; + draftConfig: Record; + entries: AwsSecretsManagerListSecretEntry[]; + nextToken: string | null; +}): SecretProviderConfigDiscoveryPreviewResult { + type DiscoverySample = { + entry: AwsSecretsManagerListSecretEntry; + name: string; + tags: Map; + prefix: string | null; + namespace: string | null; + environmentTag: string | null; + ownerTag: string | null; + kmsKeyId: string | null; + paperclipManaged: boolean; + paperclipCompanyId: string | null; + }; + + const skippedWarnings: string[] = []; + let skippedForeignPaperclipSampleCount = 0; + const samples: DiscoverySample[] = []; + + for (const entry of input.entries) { + const name = entry.Name?.trim() || entry.ARN?.trim(); + if (!name) continue; + const tags = normalizeAwsTags(entry.Tags); + const paperclipManaged = tagValue(tags, ["paperclip:managed-by"])?.toLowerCase() === "paperclip"; + const paperclipCompanyId = tagValue(tags, ["paperclip:company-id"]); + if (paperclipManaged && paperclipCompanyId !== input.companyId) { + skippedForeignPaperclipSampleCount += 1; + continue; + } + const path = inferPathSignals(entry, tags); + samples.push({ + entry, + name, + tags, + prefix: path.prefix, + namespace: path.namespace, + environmentTag: tagValue(tags, ["paperclip:environment", "environment", "env", "stage"]), + ownerTag: tagValue(tags, ["paperclip:provider-owner", "owner", "team", "service", "application"]), + kmsKeyId: asOptionalNonEmptyString(entry.KmsKeyId), + paperclipManaged, + paperclipCompanyId, + }); + } + + if (skippedForeignPaperclipSampleCount > 0) { + skippedWarnings.push( + `Skipped ${skippedForeignPaperclipSampleCount} Paperclip-managed AWS secret sample(s) that were not tagged for this company.`, + ); + } + + const draftNamespace = asOptionalNonEmptyString(input.draftConfig.namespace); + const draftPrefix = asOptionalNonEmptyString(input.draftConfig.secretNamePrefix); + const draftKmsKeyId = asOptionalNonEmptyString(input.draftConfig.kmsKeyId); + const draftEnvironmentTag = asOptionalNonEmptyString(input.draftConfig.environmentTag); + const draftOwnerTag = asOptionalNonEmptyString(input.draftConfig.ownerTag); + const groups = new Map(); + + for (const sample of samples) { + const key = [ + draftPrefix ?? sample.prefix ?? "", + draftNamespace ?? sample.namespace ?? "", + ].join("\0"); + groups.set(key, [...(groups.get(key) ?? []), sample]); + } + + const candidates = [...groups.values()] + .sort((a, b) => b.length - a.length) + .slice(0, PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT) + .map((group) => { + const prefix = draftPrefix ?? commonValue(group.map((sample) => sample.prefix)) ?? input.config.prefix; + const namespace = draftNamespace ?? commonValue(group.map((sample) => sample.namespace)) ?? null; + const environmentTag = draftEnvironmentTag ?? commonValue(group.map((sample) => sample.environmentTag)); + const ownerTag = draftOwnerTag ?? commonValue(group.map((sample) => sample.ownerTag)); + const kmsKeys = uniqueValues(group.map((sample) => sample.kmsKeyId)); + const commonKmsKey = commonValue(group.map((sample) => sample.kmsKeyId)); + const kmsKeyId = draftKmsKeyId ?? commonKmsKey; + const candidateWarnings: string[] = []; + + if (!namespace) { + candidateWarnings.push("No stable namespace signal was found in the sampled AWS secret names or tags."); + } + if (!environmentTag) { + candidateWarnings.push("No common environment tag was found in the sampled AWS secrets."); + } + if (!ownerTag) { + candidateWarnings.push("No common owner/team tag was found in the sampled AWS secrets."); + } + if (kmsKeys.length > 1 && !draftKmsKeyId) { + candidateWarnings.push("Sampled AWS secrets use multiple KMS keys; choose the intended KMS key before saving."); + } + if (group.some((sample) => sample.paperclipManaged && sample.paperclipCompanyId === input.companyId)) { + candidateWarnings.push("Sample includes Paperclip-managed secrets for this company; do not import them as external references."); + } + + return { + provider: "aws_secrets_manager" as const, + displayName: discoveryDisplayName({ + environmentTag, + ownerTag, + namespace, + secretNamePrefix: prefix, + }), + config: { + region: input.config.region, + namespace, + secretNamePrefix: prefix, + kmsKeyId: kmsKeyId ?? null, + ownerTag, + environmentTag, + }, + sampleCount: group.length, + samples: group.slice(0, PROVIDER_CONFIG_DISCOVERY_SAMPLE_LIMIT).map((sample) => ({ + name: sample.name, + hasKmsKey: Boolean(sample.kmsKeyId), + tagKeys: [...sample.tags.keys()].sort(), + })), + signals: { + namespace, + secretNamePrefix: prefix, + environmentTag, + ownerTag, + kmsKeyId: kmsKeyId ?? null, + hasKmsKey: kmsKeys.length > 0, + sampleCount: group.length, + paperclipManagedSampleCount: group.filter((sample) => sample.paperclipManaged).length, + skippedForeignPaperclipSampleCount, + }, + warnings: candidateWarnings, + }; + }); + + const warnings = [...skippedWarnings]; + if (samples.length === 0) { + warnings.push("AWS Secrets Manager returned no metadata samples for this draft provider vault config."); + } + if (groups.size > PROVIDER_CONFIG_DISCOVERY_CANDIDATE_LIMIT) { + warnings.push("Additional AWS secret name groups were omitted from this preview; refine the query to inspect them."); + } + + return { + provider: "aws_secrets_manager", + nextToken: input.nextToken, + sampledSecretCount: samples.length, + skippedForeignPaperclipSampleCount, + candidates, + warnings, + }; +} + function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial { if ( value && @@ -983,6 +1213,36 @@ export function createAwsSecretsManagerProvider( normalizeAwsError("listSecrets", error); } }, + async discoverProviderConfigs(input): Promise { + const config = resolveConfig(input.providerConfig); + const gateway = resolveGateway(config); + const query = input.query?.trim(); + const pageSize = + input.pageSize && Number.isFinite(input.pageSize) + ? Math.min(Math.max(Math.trunc(input.pageSize), 1), 100) + : 100; + + try { + if (!gateway.listSecrets) { + throw new Error("ListSecrets gateway operation is unavailable"); + } + const listed = await gateway.listSecrets({ + MaxResults: pageSize, + NextToken: input.nextToken?.trim() || undefined, + IncludePlannedDeletion: false, + Filters: query ? [{ Key: "all", Values: [query] }] : undefined, + }); + return discoverAwsProviderConfigCandidates({ + companyId: input.companyId, + config, + draftConfig: input.providerConfig.config, + entries: listed.SecretList ?? [], + nextToken: listed.NextToken ?? null, + }); + } catch (error) { + normalizeAwsError("discoverProviderConfigs", error); + } + }, async resolveVersion(input) { const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index 341163e6..9edf7766 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -1,4 +1,8 @@ -import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared"; +import type { + SecretProvider, + SecretProviderConfigDiscoveryPreviewResult, + SecretProviderDescriptor, +} from "@paperclipai/shared"; import type { DeploymentMode } from "@paperclipai/shared"; export interface StoredSecretVersionMaterial { @@ -152,6 +156,13 @@ export interface SecretProviderModule { nextToken?: string | null; pageSize?: number; }): Promise; + discoverProviderConfigs?(input: { + companyId: string; + providerConfig: SecretProviderVaultRuntimeConfig; + query?: string | null; + nextToken?: string | null; + pageSize?: number; + }): Promise; resolveVersion(input: { material: StoredSecretVersionMaterial; externalRef: string | null; diff --git a/server/src/services/access.ts b/server/src/services/access.ts index faac70da..ad58db87 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -9,6 +9,8 @@ import { } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; +import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -24,6 +26,8 @@ type MemberArchiveInput = { }; export function accessService(db: Db) { + const authorization = authorizationService(db); + async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; const row = await db @@ -58,21 +62,13 @@ export function accessService(db: Db) { principalId: string, permissionKey: PermissionKey, ): Promise { - const membership = await getMembership(companyId, principalType, principalId); - if (!membership || membership.status !== "active") return false; - const grant = await db - .select({ id: principalPermissionGrants.id }) - .from(principalPermissionGrants) - .where( - and( - eq(principalPermissionGrants.companyId, companyId), - eq(principalPermissionGrants.principalType, principalType), - eq(principalPermissionGrants.principalId, principalId), - eq(principalPermissionGrants.permissionKey, permissionKey), - ), - ) - .then((rows) => rows[0] ?? null); - return Boolean(grant); + return authorization.decidePrincipalGrant({ + companyId, + principalType, + principalId, + permissionKey, + action: permissionKey, + }).then((decision) => decision.allowed); } async function canUser( @@ -80,9 +76,20 @@ export function accessService(db: Db) { userId: string | null | undefined, permissionKey: PermissionKey, ): Promise { - if (!userId) return false; - if (await isInstanceAdmin(userId)) return true; - return hasPermission(companyId, "user", userId, permissionKey); + return authorization.decide({ + actor: { type: "board", userId }, + action: permissionKey, + resource: { type: "company", companyId }, + }).then((decision) => decision.allowed); + } + + async function decide(input: { + actor: AuthorizationActor; + action: Parameters[0]["action"]; + resource: AuthorizationResource; + scope?: Record | null; + }) { + return authorization.decide(input); } async function listMembers(companyId: string) { @@ -616,10 +623,30 @@ export function accessService(db: Db) { membership.membershipRole, "active", ); + await ensureHumanRoleDefaultGrants(db, { + companyId: targetCompanyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); } return sourceMemberships; } + async function ensureRoleDefaultGrants( + companyId: string, + principalId: string, + membershipRole: string | null | undefined, + grantedByUserId: string | null, + ) { + return ensureHumanRoleDefaultGrants(db, { + companyId, + principalId, + membershipRole, + grantedByUserId, + }); + } + async function listPrincipalGrants( companyId: string, principalType: PrincipalType, @@ -768,6 +795,7 @@ export function accessService(db: Db) { return { isInstanceAdmin, + decide, canUser, hasPermission, getMembership, @@ -776,6 +804,7 @@ export function accessService(db: Db) { listMembers, listActiveUserMemberships, copyActiveUserMemberships, + ensureRoleDefaultGrants, archiveMember, setMemberPermissions, updateMemberAndPermissions, diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index a0379c92..97cd13bf 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -18,7 +18,9 @@ export function normalizeAgentPermissions( } const record = permissions as Record; + const preserved = { ...record }; return { + ...preserved, canCreateAgents: typeof record.canCreateAgents === "boolean" ? record.canCreateAgents diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 0d4762cc..faf63723 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -554,7 +554,7 @@ export function agentService(db: Db) { const updated = await db .update(agents) .set({ - permissions: normalizeAgentPermissions(permissions, existing.role), + permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role), updatedAt: new Date(), }) .where(eq(agents.id, id)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts new file mode 100644 index 00000000..bb1d18b9 --- /dev/null +++ b/server/src/services/authorization.ts @@ -0,0 +1,823 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + companyMemberships, + instanceUserRoles, + issues, + principalPermissionGrants, + projects, +} from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; + +export type AuthorizationActor = + { + type: "board" | "agent" | "none"; + userId?: string | null; + companyIds?: string[]; + memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>; + isInstanceAdmin?: boolean; + agentId?: string | null; + companyId?: string | null; + source?: + | "local_implicit" + | "session" + | "board_key" + | "agent_key" + | "agent_jwt" + | "cloud_tenant" + | "none"; + }; + +export type AuthorizationAction = + | PermissionKey + | "agent_config:read" + | "agent_config:update" + | "issue:mutate"; + +export type AuthorizationResource = + | { type: "company"; companyId: string } + | { type: "agent"; companyId: string; agentId?: string | null } + | { + type: "issue"; + companyId: string; + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string | null; + }; + +export type AuthorizationDecision = { + allowed: boolean; + action: AuthorizationAction; + explanation: string; + reason: + | "allow_local_board" + | "allow_instance_admin" + | "allow_explicit_grant" + | "allow_legacy_agent_creator" + | "allow_self" + | "allow_company_agent" + | "allow_simple_company_member" + | "allow_manager_chain" + | "deny_unauthenticated" + | "deny_company_boundary" + | "deny_missing_membership" + | "deny_missing_grant" + | "deny_policy_restricted" + | "deny_scope" + | "deny_unsupported_action"; + grant?: { + principalType: PrincipalType; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + }; +}; + +type PrincipalGrantDecision = AuthorizationDecision & { + grant?: NonNullable; +}; + +function companyIdForResource(resource: AuthorizationResource) { + return resource.companyId; +} + +function permissionForAction(action: AuthorizationAction): PermissionKey | null { + if (action === "agent_config:read" || action === "agent_config:update") return "agents:create"; + if (action === "issue:mutate") return null; + return action; +} + +function canCreateAgentsLegacy(agent: { role: string; permissions: Record | null | undefined }) { + if (agent.role === "ceo") return true; + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean(agent.permissions.canCreateAgents); +} + +function scopeValueList(value: unknown): string[] { + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); +} + +function prefixedScopeValues(grantScope: Record, prefix: string) { + return scopeValueList(grantScope.allow) + .filter((rule) => rule.startsWith(prefix)) + .map((rule) => rule.slice(prefix.length)) + .filter((value) => value.length > 0); +} + +function scopeValuesForKeys(grantScope: Record, keys: string[]) { + return keys.flatMap((key) => scopeValueList(grantScope[key])); +} + +function scopeIncludesId(ids: string[], id: string | null | undefined) { + return Boolean(id && ids.includes(id)); +} + +function isSimpleAssignableAgentStatus(status: string | null | undefined) { + return status !== "pending_approval" && status !== "terminated"; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function objectIsEmpty(value: Record) { + return Object.keys(value).length === 0; +} + +function readPolicyObject(container: unknown, key: string): Record | null { + if (!isPlainRecord(container)) return null; + const value = container[key]; + return isPlainRecord(value) ? value : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +type AssignmentPolicyEffect = + | { kind: "none" } + | { kind: "restricted"; explanation: string } + | { kind: "requires_approval"; explanation: string } + | { kind: "unknown"; explanation: string }; + +type AgentHierarchyRow = { id: string; reportsTo: string | null }; + +function evaluateAuthorizationPolicyForAssignment( + policy: Record | null | undefined, + label: string, +): AssignmentPolicyEffect { + if (!policy || objectIsEmpty(policy)) return { kind: "none" }; + + const agentVisibility = readPolicyObject(policy, "agentVisibility"); + const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy"); + const protectedAgent = readPolicyObject(policy, "protectedAgent"); + const knownTopLevelKeys = new Set([ + "agentVisibility", + "assignmentPolicy", + "protectedAgent", + "managedBy", + ]); + const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key)); + const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent); + if (hasUnknownTopLevelKey || !hasKnownPolicySection) { + return { + kind: "unknown", + explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`, + }; + } + + const visibilityMode = readString(agentVisibility?.mode); + if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") { + return { + kind: "unknown", + explanation: `${label} has an unsupported agent visibility policy mode.`, + }; + } + + const assignmentMode = readString(assignmentPolicy?.mode); + if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") { + return { + kind: "unknown", + explanation: `${label} has an unsupported assignment policy mode.`, + }; + } + + const requiresApproval = + readBoolean(protectedAgent?.requiresApproval) === true || + readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true; + if (requiresApproval) { + return { + kind: "requires_approval", + explanation: `${label} requires approval before task assignment.`, + }; + } + + if ( + visibilityMode === "private" || + readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true + ) { + return { + kind: "restricted", + explanation: `${label} is private and cannot use simple company-wide task assignment.`, + }; + } + + if (assignmentMode === "protected") { + return { + kind: "restricted", + explanation: `${label} is protected and requires an explicit assignment grant.`, + }; + } + + return { kind: "none" }; +} + +function agentIsInSubtree( + agentsById: Map, + rootAgentId: string, + targetAgentId: string, +) { + if (rootAgentId === targetAgentId) return true; + + let cursor: string | null = targetAgentId; + for (let depth = 0; cursor && depth < 50; depth += 1) { + const current = agentsById.get(cursor); + if (!current) return false; + if (current.reportsTo === rootAgentId) return true; + cursor = current.reportsTo; + } + return false; +} + +async function loadCompanyAgentHierarchy(db: Db, companyId: string) { + const rows = await db + .select({ id: agents.id, reportsTo: agents.reportsTo }) + .from(agents) + .where(eq(agents.companyId, companyId)); + return new Map(rows.map((agent) => [agent.id, agent])); +} + +async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) { + return agentIsInSubtree( + await loadCompanyAgentHierarchy(db, companyId), + rootAgentId, + targetAgentId, + ); +} + +async function scopeAllows( + db: Db, + companyId: string, + grantScope: Record | null, + requestedScope: Record | null | undefined, + options: { requireStructuredScope?: boolean } = {}, +) { + if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope; + if (!requestedScope) return false; + + const targetAssigneeAgentId = + typeof requestedScope.assigneeAgentId === "string" + ? requestedScope.assigneeAgentId + : typeof requestedScope.targetAgentId === "string" + ? requestedScope.targetAgentId + : null; + const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null; + let constrained = false; + + const projectIds = [ + ...scopeValueList(grantScope.projectId), + ...scopeValueList(grantScope.projectIds), + ...prefixedScopeValues(grantScope, "project:"), + ]; + if (projectIds.length > 0) { + constrained = true; + if (!scopeIncludesId(projectIds, requestedProjectId)) return false; + } + + const targetAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "agentId", + "agentIds", + "assigneeAgentId", + "assigneeAgentIds", + "targetAgentId", + "targetAgentIds", + ]), + ...prefixedScopeValues(grantScope, "agent:"), + ]; + if (targetAgentIds.length > 0) { + constrained = true; + if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false; + } + + const subtreeRootAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "managerAgentId", + "managerAgentIds", + "managedSubtreeAgentId", + "managedSubtreeAgentIds", + "subtreeAgentId", + "subtreeAgentIds", + "subtreeRootAgentId", + "subtreeRootAgentIds", + ]), + ...prefixedScopeValues(grantScope, "subtree:"), + ]; + if (subtreeRootAgentIds.length > 0) { + constrained = true; + if (!targetAssigneeAgentId) return false; + const agentsById = await loadCompanyAgentHierarchy(db, companyId); + let matchesSubtree = false; + for (const rootAgentId of subtreeRootAgentIds) { + if (agentIsInSubtree(agentsById, rootAgentId, targetAssigneeAgentId)) { + matchesSubtree = true; + break; + } + } + if (!matchesSubtree) return false; + } + + // Unknown metadata keys do not constrain the grant. Recognized constraints + // return false above when they fail to match the requested assignment scope. + return !constrained ? true : constrained; +} + +function allow(input: Omit): AuthorizationDecision { + return { ...input, allowed: true }; +} + +function deny(input: Omit): AuthorizationDecision { + return { ...input, allowed: false }; +} + +export function authorizationService(db: Db) { + async function isInstanceAdmin(userId: string | null | undefined): Promise { + if (!userId) return false; + if ( + await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null) + ) { + return true; + } + return false; + } + + async function getActiveMembership( + companyId: string, + principalType: PrincipalType, + principalId: string, + ) { + return db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, principalType), + eq(companyMemberships.principalId, principalId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function findGrant( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + ) { + return db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function decidePrincipalGrant(input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + action: AuthorizationAction; + permissionKey: PermissionKey; + scope?: Record | null; + }): Promise { + const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId); + if (!membership) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`, + }); + } + + const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); + if (!grant) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: `Missing permission: ${input.permissionKey}.`, + }); + } + + if ( + !(await scopeAllows(db, input.companyId, grant.scope, input.scope, { + requireStructuredScope: input.permissionKey === "tasks:assign_scope", + })) + ) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: `Permission ${input.permissionKey} does not cover the requested scope.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + return allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation: `Allowed by explicit grant ${input.permissionKey}.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + async function loadAgent(agentId: string) { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + status: agents.status, + reportsTo: agents.reportsTo, + permissions: agents.permissions, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + } + + async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) { + const row = await db + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy"); + } + + async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) { + const row = await db + .select({ executionPolicy: issues.executionPolicy }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return readPolicyObject(row?.executionPolicy, "authorizationPolicy"); + } + + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { + if (resource.type !== "issue") return true; + if (resource.assigneeAgentId) { + const target = await loadAgent(resource.assigneeAgentId); + return Boolean( + target && + target.companyId === resource.companyId && + isSimpleAssignableAgentStatus(target.status), + ); + } + if (resource.assigneeUserId) { + return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId)); + } + return true; + } + + async function assignmentPolicyEffect(resource: AuthorizationResource): Promise { + if (resource.type !== "issue") return { kind: "none" }; + + const checks: Array> = []; + if (resource.assigneeAgentId) { + checks.push( + loadAgent(resource.assigneeAgentId).then((agent) => + evaluateAuthorizationPolicyForAssignment( + readPolicyObject(agent?.permissions, "authorizationPolicy"), + "Target agent", + ), + ), + ); + } + if (resource.projectId) { + checks.push( + loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target project"), + ), + ); + } + if (resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Target issue"), + ), + ); + } + if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) { + checks.push( + loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) => + evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"), + ), + ); + } + if (checks.length === 0) return { kind: "none" }; + + const effects = await Promise.all(checks); + return ( + effects.find((effect) => effect.kind === "unknown") ?? + effects.find((effect) => effect.kind === "requires_approval") ?? + effects.find((effect) => effect.kind === "restricted") ?? + { kind: "none" } + ); + } + + async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { + return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId); + } + + async function decide(input: { + actor: AuthorizationActor; + action: AuthorizationAction; + resource: AuthorizationResource; + scope?: Record | null; + }): Promise { + const permissionKey = permissionForAction(input.action); + const companyId = companyIdForResource(input.resource); + + async function decideWithTaskAssignmentGrants( + principalType: PrincipalType, + principalId: string, + ): Promise { + const broadDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign", + scope: input.scope, + }); + if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision; + const scopedDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign_scope", + scope: input.scope, + }); + if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision; + return broadDecision; + } + + async function denyForAssignmentPolicyIfNeeded( + policyEffect: AssignmentPolicyEffect, + ): Promise { + if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null; + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: policyEffect.explanation, + }); + } + + function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision { + return deny({ + action: input.action, + reason: "deny_policy_restricted", + explanation: + policyEffect.kind === "restricted" + ? policyEffect.explanation + : "Restrictive authorization policy blocks simple company-wide task assignment.", + }); + } + + if (input.actor.type === "none") { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Authentication required.", + }); + } + + if (input.actor.type === "board") { + let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null; + if (input.actor.source === "local_implicit") { + return allow({ + action: input.action, + reason: "allow_local_board", + explanation: "Allowed because the actor is the local implicit board.", + }); + } + if (input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId)) { + return allow({ + action: input.action, + reason: "allow_instance_admin", + explanation: "Allowed because the actor is an instance admin.", + }); + } + if (!input.actor.userId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Board user id is required.", + }); + } + if (input.action === "tasks:assign") { + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + const policyEffect = await assignmentPolicyEffect(input.resource); + taskAssignmentPolicyEffect = policyEffect; + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; + const membership = await getActiveMembership(companyId, "user", input.actor.userId); + if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") { + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by simple mode company-wide task assignment default.", + }); + } + } + if (!permissionKey) { + return deny({ + action: input.action, + reason: "deny_unsupported_action", + explanation: `No board permission mapping exists for ${input.action}.`, + }); + } + if (input.action === "tasks:assign") { + const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId); + if (grantDecision.allowed) return grantDecision; + const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource); + if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect); + return grantDecision; + } + return decidePrincipalGrant({ + companyId, + principalType: "user", + principalId: input.actor.userId, + action: input.action, + permissionKey, + scope: input.scope, + }); + } + + const actorAgentId = input.actor.agentId ?? null; + if (!actorAgentId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Agent authentication required.", + }); + } + if (input.actor.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Agent key cannot access another company.", + }); + } + + const actorAgent = await loadAgent(actorAgentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Actor agent was not found in the target company.", + }); + } + + if (input.action === "tasks:assign") { + if (!isSimpleAssignableAgentStatus(actorAgent.status)) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: "Actor agent is not active for simple mode task assignment.", + }); + } + if (!(await assignmentTargetIsInCompany(input.resource))) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Task assignment target agent is not active in the target company.", + }); + } + const policyEffect = await assignmentPolicyEffect(input.resource); + const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect); + if (policyDeny) return policyDeny; + if (policyEffect.kind === "restricted") { + const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); + if (grantDecision.allowed) return grantDecision; + return denyRestrictedAssignmentPolicy(policyEffect); + } + return allow({ + action: input.action, + reason: "allow_simple_company_member", + explanation: "Allowed by simple mode company-wide task assignment default.", + }); + } + + if (input.action === "issue:mutate") { + const resource = input.resource.type === "issue" ? input.resource : null; + if (resource?.assigneeAgentId === actorAgentId) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor owns the assigned issue.", + }); + } + if (!resource?.assigneeAgentId) { + return allow({ + action: input.action, + reason: "allow_company_agent", + explanation: "Allowed because the issue has no agent assignee.", + }); + } + } + if ( + input.action === "agent_config:update" && + input.resource.type === "agent" && + input.resource.agentId === actorAgentId + ) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor is updating its own agent configuration.", + }); + } + + if (permissionKey) { + const grantDecision = await decidePrincipalGrant({ + companyId, + principalType: "agent", + principalId: actorAgentId, + action: input.action, + permissionKey, + scope: input.scope, + }); + if (grantDecision.allowed) return grantDecision; + } + + if ( + (input.action === "agents:create" || + input.action === "agent_config:read" || + input.action === "agent_config:update" || + input.action === "tasks:manage_active_checkouts") && + canCreateAgentsLegacy(actorAgent) + ) { + return allow({ + action: input.action, + reason: "allow_legacy_agent_creator", + explanation: "Allowed by legacy agent creator authority.", + }); + } + + if ( + input.action === "tasks:manage_active_checkouts" && + input.resource.type === "issue" && + input.resource.assigneeAgentId && + await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId) + ) { + return allow({ + action: input.action, + reason: "allow_manager_chain", + explanation: "Allowed because the actor manages the issue assignee in the reporting chain.", + }); + } + + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: permissionKey + ? `Missing permission: ${permissionKey}.` + : `No agent permission mapping exists for ${input.action}.`, + }); + } + + return { + decide, + decidePrincipalGrant, + }; +} diff --git a/server/src/services/cloud-upstreams.ts b/server/src/services/cloud-upstreams.ts new file mode 100644 index 00000000..576d0daf --- /dev/null +++ b/server/src/services/cloud-upstreams.ts @@ -0,0 +1,1309 @@ +import crypto, { sign } from "node:crypto"; +import { and, count, desc, eq, sql } from "drizzle-orm"; +import type { + CloudUpstreamConnectStartResponse, + CloudUpstreamActivationDecision, + CloudUpstreamActivationEntityType, + CloudUpstreamConnection, + CloudUpstreamConflict, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamRunEvent, + CloudUpstreamsState, + CloudUpstreamSummaryCount, + CloudUpstreamTarget, + CloudUpstreamWarning, + CompanyPortabilityExportResult, + CompanyPortabilityFileEntry, +} from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; +import { + agents, + cloudUpstreamConnections, + cloudUpstreamRuns, + companies, + goals, + issueComments, + issues, + projects, + routines, +} from "@paperclipai/db"; +import { badRequest, conflict, HttpError, notFound } from "../errors.js"; +import { companyPortabilityService } from "./company-portability.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; + +const DEFAULT_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; +const TRANSFER_SCHEMA = { + family: "paperclip-upstream-transfer", + version: "1.0.0", + major: 1, + minor: 0, +} as const; +const DEFAULT_MAX_ENTITIES_PER_CHUNK = 100; +const DISCOVERY_FETCH_TIMEOUT_MS = 30_000; +const REMOTE_FETCH_TIMEOUT_MS = 120_000; +const CLOUD_CREDENTIAL_PREFIX = "paperclip-cloud-credential:"; + +type NormalizedSha256 = `sha256:${string}`; + +type SourceEntityKey = { + sourceInstanceId: string; + sourceCompanyId: string; + sourceEntityType: string; + sourceEntityId: string; + sourceNaturalKey?: string; +}; + +type UpstreamTransferWarning = { + code: string; + severity: "info" | "warning" | "blocker"; + message: string; + entity?: SourceEntityKey; +}; + +type UpstreamTransferEntityRecord = { + key: SourceEntityKey; + contentHash: NormalizedSha256; + dependencies: SourceEntityKey[]; + warnings: UpstreamTransferWarning[]; +}; + +type LocalUpstreamExportEntity = { + record: UpstreamTransferEntityRecord; + body: Record; + conflictKeys?: string[]; +}; + +type LocalUpstreamExportChunk = { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + payload: { + entityKeys: SourceEntityKey[]; + }; +}; + +type UpstreamTransferManifest = { + schema: typeof TRANSFER_SCHEMA; + source: { + sourceInstanceId: string; + sourceCompanyId: string; + sourceInstanceKeyFingerprint: string; + exporterVersion: string; + sourceSchemaVersion: string; + }; + target: { + targetStackId: string; + targetCompanyId: string; + targetOrigin: string; + supportedSchemaMajor: number; + }; + runId: string; + idempotencyKey: string; + generatedAt: string; + entityCount: number; + perEntityTypeCounts: Record; + entities: UpstreamTransferEntityRecord[]; + chunks: Array & { manifestHash: NormalizedSha256 }>; + warnings: UpstreamTransferWarning[]; + featureFlags: string[]; + manifestHash: NormalizedSha256; +}; + +type LocalUpstreamExportBundle = { + manifest: UpstreamTransferManifest; + entities: LocalUpstreamExportEntity[]; + chunks: LocalUpstreamExportChunk[]; +}; + +type ConnectionRow = typeof cloudUpstreamConnections.$inferSelect; +type RunRow = typeof cloudUpstreamRuns.$inferSelect; + +export function cloudUpstreamService(db: Db, options: { instanceId?: string } = {}) { + const sourceInstanceId = `paperclip-local-${options.instanceId ?? "default"}`; + const portability = companyPortabilityService(db); + + return { + list: async (companyId: string): Promise => { + const [connectionRows, runRows] = await Promise.all([ + db + .select() + .from(cloudUpstreamConnections) + .where(eq(cloudUpstreamConnections.companyId, companyId)) + .orderBy(desc(cloudUpstreamConnections.updatedAt)), + db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.companyId, companyId)) + .orderBy(desc(cloudUpstreamRuns.createdAt)) + .limit(50), + ]); + return { + connections: connectionRows.map(connectionFromRow), + runs: runRows.map(runFromRow), + }; + }, + + startConnect: async (input: { + companyId: string; + remoteUrl: string; + redirectUri: string; + }): Promise => { + await requireCompany(input.companyId); + const remoteUrl = input.remoteUrl.trim(); + if (!remoteUrl) throw badRequest("Remote URL is required"); + + const discovery = await fetchDiscovery(remoteUrl); + const target = targetFromDiscovery(discovery); + const connectionId = crypto.randomUUID(); + const state = crypto.randomBytes(24).toString("base64url"); + const codeVerifier = crypto.randomBytes(32).toString("base64url"); + const codeChallenge = crypto.createHash("sha256").update(codeVerifier, "utf8").digest("base64url"); + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = `sha256:${crypto + .createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; + + const [row] = await db.insert(cloudUpstreamConnections).values({ + id: connectionId, + companyId: input.companyId, + remoteUrl, + sourceInstanceId, + sourceInstanceFingerprint, + sourcePublicKey, + privateKeyPem: await sealCloudUpstreamCredential(privateKey.export({ type: "pkcs8", format: "pem" }).toString()), + tokenStatus: "pending", + scopes: scopesFromDiscovery(discovery), + targetStackId: target.stackId, + targetStackSlug: target.stackSlug, + targetStackDisplayName: target.stackDisplayName, + targetCompanyId: target.companyId, + targetOrigin: target.origin, + targetPrimaryHost: target.primaryHost, + targetProduct: target.product, + targetSchemaMajor: target.schemaMajor, + targetMaxChunkBytes: target.maxChunkBytes, + pendingState: state, + pendingCodeVerifier: await sealCloudUpstreamCredential(codeVerifier), + pendingRedirectUri: input.redirectUri, + pendingTokenUrl: tokenUrlFromDiscovery(discovery), + }).returning(); + if (!row) throw badRequest("Failed to create cloud upstream connection"); + + const authorizationUrl = new URL(consentUrlFromDiscovery(discovery)); + authorizationUrl.searchParams.set("stackId", target.stackId); + authorizationUrl.searchParams.set("redirectUri", input.redirectUri); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("codeChallenge", codeChallenge); + authorizationUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizationUrl.searchParams.set("sourceInstanceId", sourceInstanceId); + authorizationUrl.searchParams.set("sourceInstanceFingerprint", sourceInstanceFingerprint); + authorizationUrl.searchParams.set("sourcePublicKey", sourcePublicKey); + authorizationUrl.searchParams.set("scopes", row.scopes.join(" ")); + + return { + pendingConnectionId: row.id, + authorizationUrl: authorizationUrl.toString(), + connection: connectionFromRow(row), + }; + }, + + finishConnect: async (input: { + pendingConnectionId: string; + code: string; + state: string; + }): Promise => { + const pending = await getConnectionRow(input.pendingConnectionId); + if (!pending.pendingState || !pending.pendingCodeVerifier || !pending.pendingRedirectUri || !pending.pendingTokenUrl) { + throw notFound("Pending cloud upstream connection was not found"); + } + if (input.state !== pending.pendingState) throw badRequest("Cloud upstream state did not match"); + const tokenResponse = await postJson>(pending.pendingTokenUrl, { + grantType: "authorization_code", + code: input.code, + redirectUri: pending.pendingRedirectUri, + codeVerifier: await unsealCloudUpstreamCredential(pending.pendingCodeVerifier), + }); + const accessToken = stringField(tokenResponse, "accessToken"); + const token = objectField(tokenResponse, "token"); + const expiresAt = optionalString(token.expiresAt) ?? optionalString(tokenResponse.expiresAt); + const [updated] = await db + .update(cloudUpstreamConnections) + .set({ + tokenStatus: "connected", + authorizedGlobalUserId: optionalString(token.globalUserId), + accessToken: await sealCloudUpstreamCredential(accessToken), + tokenId: optionalString(token.id), + tokenExpiresAt: expiresAt ? new Date(expiresAt) : null, + pendingState: null, + pendingCodeVerifier: null, + pendingRedirectUri: null, + pendingTokenUrl: null, + updatedAt: new Date(), + }) + .where(eq(cloudUpstreamConnections.id, pending.id)) + .returning(); + if (!updated) throw notFound("Cloud upstream connection was not found"); + return connectionFromRow(updated); + }, + + preview: async (connectionId: string, companyId: string): Promise => { + const connection = await getConnectionRow(connectionId, companyId); + const basePreview = await localPreview(connection); + if (!basePreview.schemaCompatible || connection.tokenStatus !== "connected") { + return basePreview; + } + + const bundle = await buildBundle(connection, "preview"); + const conflictKeysBySource: Record = {}; + for (const entity of bundle.entities) { + if (!entity.conflictKeys || entity.conflictKeys.length === 0) continue; + conflictKeysBySource[sourceEntityKeyString(entity.record.key)] = [...entity.conflictKeys]; + } + const remotePreview = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/preview`, { + manifest: bundle.manifest, + previewShape: "manifest_only", + conflictKeysBySource, + }); + return { + ...basePreview, + warnings: mergeWarnings(basePreview.warnings, warningsFromRemote(remotePreview)), + conflicts: conflictsFromRemote(remotePreview), + }; + }, + + createRun: async (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }): Promise => { + const connection = await getConnectionRow(input.connectionId, input.companyId); + if (connection.tokenStatus !== "connected") { + throw badRequest("Cloud upstream connection is not connected"); + } + await assertNoRunningRun(input.connectionId, input.companyId, db); + const preview = await localPreview(connection); + if (!preview.schemaCompatible) { + throw badRequest("Cloud stack schema is not compatible with this local Paperclip version"); + } + + const bundle = await buildBundle(connection, "apply"); + const runId = crypto.randomUUID(); + const now = new Date(); + const initialEvents = [ + event(now.toISOString(), "connect", "completed", "Connected to the target Paperclip Cloud stack."), + event(now.toISOString(), "scan", "completed", "Scanned the local company inventory."), + event(now.toISOString(), "preview", "completed", "Generated the transfer manifest."), + ...(input.retryOfRunId + ? [event(now.toISOString(), "push", "retrying", `Retrying run ${input.retryOfRunId} with the same import ledger idempotency key.`)] + : []), + ]; + const created = await db.transaction(async (tx) => { + await tx.execute( + sql`select ${cloudUpstreamConnections.id} from ${cloudUpstreamConnections} where ${cloudUpstreamConnections.id} = ${connection.id} and ${cloudUpstreamConnections.companyId} = ${connection.companyId} for update`, + ); + await assertNoRunningRun(input.connectionId, input.companyId, tx); + const [row] = await tx.insert(cloudUpstreamRuns).values({ + id: runId, + connectionId: connection.id, + companyId: connection.companyId, + status: "running", + activeStep: "push", + progressPercent: 45, + dryRun: false, + retryOfRunId: input.retryOfRunId ?? null, + summary: preview.summary, + warnings: preview.warnings, + conflicts: preview.conflicts, + events: initialEvents, + report: {}, + idempotencyKey: bundle.manifest.idempotencyKey, + manifestHash: bundle.manifest.manifestHash, + targetUrl: connection.targetOrigin, + createdAt: now, + updatedAt: now, + }).returning(); + return row; + }); + if (!created) throw badRequest("Failed to create cloud upstream run"); + + try { + const remoteRun = await remotePost(connection, `/api/companies/${encodeURIComponent(connection.targetCompanyId)}/upstream-imports/runs`, { + mode: "apply", + manifest: bundle.manifest, + entities: bundle.entities, + }); + const remoteRunId = remoteRunIdFromResponse(remoteRun); + const pushedRun = await updateRunIfRunning(runId, { + remoteRunId, + activeStep: "push", + progressPercent: 60, + events: [ + ...initialEvents, + event(new Date().toISOString(), "push", "updated", "Created or resumed the cloud import ledger run."), + ], + }); + if (pushedRun.status !== "running") return pushedRun; + + for (const chunk of bundle.chunks) { + await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/chunks`, chunk); + } + const verifiedRun = await updateRunIfRunning(runId, { + activeStep: "verify", + progressPercent: 82, + events: [ + ...initialEvents, + event(new Date().toISOString(), "push", "completed", `Uploaded ${bundle.chunks.length} manifest chunk${bundle.chunks.length === 1 ? "" : "s"}.`), + ], + }); + if (verifiedRun.status !== "running") return verifiedRun; + + const applied = await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/apply`, {}); + const remoteEvents = await remoteGet(connection, `/api/upstream-import-runs/${encodeURIComponent(remoteRunId)}/events`).catch(() => null); + const completedAt = new Date(); + const finalEvents = [ + ...initialEvents, + event(completedAt.toISOString(), "push", "completed", "Pushed mapped objects without duplicate creation."), + event(completedAt.toISOString(), "verify", "completed", "Verified the cloud import ledger and generated a run report."), + event(completedAt.toISOString(), "activate", "completed", "Activation checklist is ready for manual unpause decisions."), + ...eventsFromRemote(remoteEvents), + ]; + const finalRun = await updateRunIfRunning(runId, { + remoteRunId, + status: "succeeded", + activeStep: "activate", + progressPercent: 100, + warnings: mergeWarnings(preview.warnings, warningsFromRemote(applied)), + conflicts: conflictsFromRemote(applied), + events: finalEvents, + report: { + runId, + remoteRunId, + target: targetFromConnectionRow(connection), + manifestHash: bundle.manifest.manifestHash, + idempotencyKey: bundle.manifest.idempotencyKey, + retryOfRunId: input.retryOfRunId ?? null, + result: applied, + events: remoteEvents, + }, + completedAt, + }); + if (finalRun.status === "succeeded") { + await db + .update(cloudUpstreamConnections) + .set({ lastRunId: finalRun.id, updatedAt: new Date() }) + .where(eq(cloudUpstreamConnections.id, connection.id)); + } + return finalRun; + } catch (error) { + const failedAt = new Date(); + const failure = cloudUpstreamRemoteFailureReport(error); + return updateRunIfRunning(runId, { + status: "failed", + activeStep: "push", + progressPercent: 100, + events: [ + ...initialEvents, + event(failedAt.toISOString(), "push", "failed", failure.errorMessage ?? failure.error), + ], + report: { + runId, + target: targetFromConnectionRow(connection), + manifestHash: bundle.manifest.manifestHash, + idempotencyKey: bundle.manifest.idempotencyKey, + retryOfRunId: input.retryOfRunId ?? null, + ...failure, + }, + completedAt: failedAt, + }); + } + }, + + readRun: async (connectionId: string, runId: string, companyId: string): Promise => { + const row = await getRunRow(connectionId, runId, companyId); + return runFromRow(row); + }, + + cancelRun: async (connectionId: string, runId: string, companyId: string): Promise => { + const row = await getRunRow(connectionId, runId, companyId); + if (row.status !== "running") return runFromRow(row); + const connection = await getConnectionRow(connectionId, companyId); + if (row.remoteRunId) { + await remotePost(connection, `/api/upstream-import-runs/${encodeURIComponent(row.remoteRunId)}/cancel`, {}).catch(() => null); + } + return updateRun(row.id, { + status: "cancelled", + activeStep: "push", + progressPercent: 100, + completedAt: new Date(), + events: [ + ...row.events, + event(new Date().toISOString(), "push", "failed", "Push cancelled locally before remote apply completed."), + ], + }); + }, + + activateRunEntities: async (input: { + connectionId: string; + runId: string; + companyId: string; + entityType: CloudUpstreamActivationEntityType; + }): Promise => { + const row = await getRunRow(input.connectionId, input.runId, input.companyId); + assertActivationEntityType(input.entityType); + if (row.status !== "succeeded") { + throw badRequest("Only succeeded cloud upstream runs can activate imported entities"); + } + + const activatedAt = new Date().toISOString(); + const count = summaryCount(row.summary, input.entityType); + const nextDecision: CloudUpstreamActivationDecision = { + entityType: input.entityType, + count, + status: "activated", + activatedAt, + }; + const report = asRecord(row.report); + const activationChecklist = activationChecklistFromReport(report); + const label = activationEntityLabel(input.entityType, count); + + return updateRun(row.id, { + report: { + ...report, + activationChecklist: { + ...activationChecklist, + [input.entityType]: nextDecision, + }, + }, + events: [ + ...row.events, + event(activatedAt, "activate", "completed", `Activated ${count} imported ${label}.`), + ], + }); + }, + }; + + async function requireCompany(companyId: string) { + const row = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)).then((rows) => rows[0]); + if (!row) throw notFound("Company was not found"); + } + + async function getConnectionRow(connectionId: string, companyId?: string): Promise { + const row = await db + .select() + .from(cloudUpstreamConnections) + .where(companyId + ? and(eq(cloudUpstreamConnections.id, connectionId), eq(cloudUpstreamConnections.companyId, companyId)) + : eq(cloudUpstreamConnections.id, connectionId)) + .then((rows) => rows[0]); + if (!row) throw notFound("Cloud upstream connection was not found"); + return row; + } + + async function getRunRow(connectionId: string, runId: string, companyId: string): Promise { + const row = await db + .select() + .from(cloudUpstreamRuns) + .where(and( + eq(cloudUpstreamRuns.id, runId), + eq(cloudUpstreamRuns.connectionId, connectionId), + eq(cloudUpstreamRuns.companyId, companyId), + )) + .then((rows) => rows[0]); + if (!row) throw notFound("Cloud upstream run was not found"); + return row; + } + + async function assertNoRunningRun( + connectionId: string, + companyId: string, + database: Pick, + ) { + const [running] = await database + .select({ id: cloudUpstreamRuns.id }) + .from(cloudUpstreamRuns) + .where(and( + eq(cloudUpstreamRuns.connectionId, connectionId), + eq(cloudUpstreamRuns.companyId, companyId), + eq(cloudUpstreamRuns.status, "running"), + )) + .limit(1); + if (running) { + throw conflict("A cloud upstream run is already running for this connection", { runId: running.id }); + } + } + + async function updateRun(runId: string, patch: Partial): Promise { + const [updated] = await db + .update(cloudUpstreamRuns) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(cloudUpstreamRuns.id, runId)) + .returning(); + if (!updated) throw notFound("Cloud upstream run was not found"); + return runFromRow(updated); + } + + async function updateRunIfRunning(runId: string, patch: Partial): Promise { + const [updated] = await db + .update(cloudUpstreamRuns) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(cloudUpstreamRuns.id, runId), eq(cloudUpstreamRuns.status, "running"))) + .returning(); + if (updated) return runFromRow(updated); + + const [current] = await db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.id, runId)) + .limit(1); + if (!current) throw notFound("Cloud upstream run was not found"); + return runFromRow(current); + } + + async function localPreview(connection: ConnectionRow): Promise { + return { + connectionId: connection.id, + sourceCompanyId: connection.companyId, + target: targetFromConnectionRow(connection), + schemaCompatible: connection.targetSchemaMajor === TRANSFER_SCHEMA.major, + summary: await buildSummary(connection.companyId), + warnings: buildWarnings(connection.targetSchemaMajor), + conflicts: [], + generatedAt: new Date().toISOString(), + }; + } + + async function buildSummary(companyId: string): Promise { + const [agentCount, projectCount, goalCount, issueCount, commentCount, routineCount] = await Promise.all([ + db.select({ count: count() }).from(agents).where(eq(agents.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(projects).where(eq(projects.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(goals).where(eq(goals.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issues).where(eq(issues.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(issueComments).where(eq(issueComments.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + db.select({ count: count() }).from(routines).where(eq(routines.companyId, companyId)).then((rows) => rows[0]?.count ?? 0), + ]); + return [ + { key: "companies", label: "Companies", count: 1 }, + { key: "goals", label: "Goals", count: goalCount }, + { key: "projects", label: "Projects", count: projectCount }, + { key: "agents", label: "Agents", count: agentCount }, + { key: "issues", label: "Issues", count: issueCount }, + { key: "comments", label: "Comments", count: commentCount }, + { key: "routines", label: "Routines", count: routineCount }, + { key: "warnings", label: "Warnings", count: buildWarnings(TRANSFER_SCHEMA.major).length }, + ]; + } + + async function buildBundle(connection: ConnectionRow, mode: "preview" | "apply"): Promise { + const exported = await portability.exportBundle(connection.companyId, { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + expandReferencedSkills: true, + }); + const sourceHash = normalizedContentHash({ + manifest: exported.manifest, + files: exported.files, + }); + const source = { + sourceInstanceId: connection.sourceInstanceId, + sourceCompanyId: connection.companyId, + sourceInstanceKeyFingerprint: connection.sourceInstanceFingerprint, + exporterVersion: "paperclip-local-cloud-ui-v1", + sourceSchemaVersion: TRANSFER_SCHEMA.version, + }; + const target = { + targetStackId: connection.targetStackId, + targetCompanyId: connection.targetCompanyId, + targetOrigin: connection.targetOrigin, + supportedSchemaMajor: connection.targetSchemaMajor, + }; + const idempotencyKey = [ + mode, + connection.sourceInstanceId, + connection.companyId, + connection.targetStackId, + sourceHash, + ].join(":"); + return buildLocalUpstreamExportBundle({ + source, + target, + runId: `local-${mode}-${shortHash(idempotencyKey)}`, + idempotencyKey, + entities: buildEntitiesFromPortableExport(connection.companyId, connection.sourceInstanceId, exported), + warnings: exported.warnings.map((message): UpstreamTransferWarning => ({ + code: "local_company_export_warning", + severity: "warning", + message, + })), + featureFlags: ["cloud_sync"], + maxEntitiesPerChunk: DEFAULT_MAX_ENTITIES_PER_CHUNK, + }); + } +} + +async function fetchDiscovery(remoteUrl: string): Promise> { + const parsed = new URL(remoteUrl); + if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") { + throw badRequest("Cloud upstream targets require HTTPS except localhost development"); + } + const stackId = firstPathSegment(parsed.pathname); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", parsed.origin); + if (stackId) { + discoveryUrl.searchParams.set("stackId", stackId); + } + const response = await fetchWithTimeout(discoveryUrl, undefined, DISCOVERY_FETCH_TIMEOUT_MS); + if (!response.ok) { + throw badRequest(`Cloud upstream discovery failed: ${response.status}`); + } + return await response.json() as Record; +} + +export async function reconcileCloudUpstreamRunsOnStartup(db: Db, now = new Date()): Promise<{ reconciled: number }> { + const runningRows = await db + .select() + .from(cloudUpstreamRuns) + .where(eq(cloudUpstreamRuns.status, "running")); + if (runningRows.length === 0) return { reconciled: 0 }; + + for (const row of runningRows) { + const report = asRecord(row.report); + await db + .update(cloudUpstreamRuns) + .set({ + status: "failed", + activeStep: row.activeStep, + progressPercent: 100, + completedAt: now, + updatedAt: now, + events: [ + ...safeRunEvents(row.events), + event( + now.toISOString(), + cloudUpstreamStep(row.activeStep), + "failed", + "Marked failed on server startup because the previous process stopped while the cloud upstream run was in progress.", + ), + ], + report: { + ...report, + error: optionalString(report.error) ?? "orphaned_running_run", + errorMessage: optionalString(report.errorMessage) + ?? "The server restarted while this cloud upstream run was running, so Paperclip marked it failed instead of leaving it stuck.", + reconciledAt: now.toISOString(), + }, + }) + .where(eq(cloudUpstreamRuns.id, row.id)); + } + + return { reconciled: runningRows.length }; +} + +function firstPathSegment(pathname: string): string | null { + const segment = pathname.split("/").find(Boolean); + return segment && segment.toLowerCase() !== "dashboard" ? segment : null; +} + +function targetFromDiscovery(discovery: Record): CloudUpstreamTarget { + const stack = objectField(discovery, "stack"); + const transfer = objectField(discovery, "transfer"); + const schema = optionalObject(transfer.schema); + const origin = stringField(stack, "origin"); + return { + stackId: stringField(stack, "id"), + stackSlug: optionalString(stack.slug), + stackDisplayName: optionalString(stack.displayName), + companyId: stringField(stack, "companyId"), + primaryHost: optionalString(stack.primaryHost) ?? new URL(origin).host, + origin, + product: optionalString(discovery.product) ?? "Paperclip Cloud", + schemaMajor: optionalNumber(schema?.major) ?? numberField(transfer, "supportedSchemaMajor"), + maxChunkBytes: optionalNumber(transfer.maxChunkBytes) ?? 8 * 1024 * 1024, + }; +} + +function targetFromConnectionRow(row: ConnectionRow): CloudUpstreamTarget { + return { + stackId: row.targetStackId, + stackSlug: row.targetStackSlug, + stackDisplayName: row.targetStackDisplayName, + companyId: row.targetCompanyId, + primaryHost: row.targetPrimaryHost, + origin: row.targetOrigin, + product: row.targetProduct, + schemaMajor: row.targetSchemaMajor, + maxChunkBytes: row.targetMaxChunkBytes, + }; +} + +function connectionFromRow(row: ConnectionRow): CloudUpstreamConnection { + return { + id: row.id, + companyId: row.companyId, + remoteUrl: row.remoteUrl, + target: targetFromConnectionRow(row), + tokenStatus: cloudUpstreamTokenStatus(row.tokenStatus), + scopes: row.scopes, + authorizedGlobalUserId: row.authorizedGlobalUserId, + expiresAt: row.tokenExpiresAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + lastRunId: row.lastRunId, + }; +} + +function runFromRow(row: RunRow): CloudUpstreamRun { + return { + id: row.id, + connectionId: row.connectionId, + companyId: row.companyId, + status: cloudUpstreamRunStatus(row.status), + activeStep: cloudUpstreamStep(row.activeStep), + progressPercent: row.progressPercent, + dryRun: row.dryRun, + summary: row.summary, + warnings: row.warnings, + conflicts: row.conflicts, + events: row.events, + targetUrl: row.targetUrl, + report: row.report, + retryOfRunId: row.retryOfRunId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + }; +} + +function scopesFromDiscovery(discovery: Record): string[] { + const auth = objectField(discovery, "auth"); + const scopes = Array.isArray(auth.scopes) ? auth.scopes.map(String).filter(Boolean) : []; + return scopes.length > 0 ? scopes : [...DEFAULT_SCOPES]; +} + +function consentUrlFromDiscovery(discovery: Record): string { + const pkce = objectField(objectField(discovery, "auth"), "pkce"); + return optionalString(pkce.consentUrl) ?? stringField(pkce, "authorizeUrl"); +} + +function tokenUrlFromDiscovery(discovery: Record): string { + return stringField(objectField(objectField(discovery, "auth"), "pkce"), "tokenUrl"); +} + +function buildWarnings(schemaMajor: number): CloudUpstreamWarning[] { + const warnings: CloudUpstreamWarning[] = [ + { + code: "imported_automations_paused", + severity: "warning", + title: "Automations stay paused", + detail: "Imported agents, routines, and monitors require explicit activation after the push.", + }, + { + code: "unmatched_users_import_as_historical_authors", + severity: "warning", + title: "Unmatched users become historical authors", + detail: "Invite now remains a secondary action after the transfer is complete.", + }, + { + code: "secret_values_redacted", + severity: "warning", + title: "Secret values are not transferred", + detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.", + }, + ]; + if (schemaMajor !== TRANSFER_SCHEMA.major) { + warnings.unshift({ + code: "schema_mismatch", + severity: "blocker", + title: "Cloud stack upgrade required", + detail: `This local build uses upstream schema ${TRANSFER_SCHEMA.major}, but the cloud stack reports schema ${schemaMajor}.`, + }); + } + return warnings; +} + +type LocalUpstreamExportEntityInput = { + key: SourceEntityKey; + body: Record; + dependencies?: SourceEntityKey[]; + warnings?: UpstreamTransferWarning[]; + conflictKeys?: string[]; +}; + +function buildEntitiesFromPortableExport( + localCompanyId: string, + sourceInstanceId: string, + exported: CompanyPortabilityExportResult, +): LocalUpstreamExportEntityInput[] { + const companyKey: SourceEntityKey = { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company", + sourceEntityId: localCompanyId, + sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId, + }; + const entities: LocalUpstreamExportEntityInput[] = [ + { + key: companyKey, + body: { + kind: "paperclip_company_portability_manifest", + manifest: exported.manifest, + rootPath: exported.rootPath, + paperclipExtensionPath: exported.paperclipExtensionPath, + fileCount: Object.keys(exported.files).length, + }, + conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`], + }, + ]; + + for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) { + entities.push({ + key: { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company_setting", + sourceEntityId: shortHash(filePath), + sourceNaturalKey: filePath, + }, + body: { + kind: "paperclip_portable_file", + path: filePath, + entry: normalizePortableFileEntry(entry), + }, + dependencies: [companyKey], + conflictKeys: [`portable_file:${filePath}`], + }); + } + return entities; +} + +function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record { + if (typeof entry === "string") { + return { encoding: "utf8", data: entry }; + } + return { ...entry }; +} + +function buildLocalUpstreamExportBundle(input: { + source: UpstreamTransferManifest["source"]; + target: UpstreamTransferManifest["target"]; + runId: string; + idempotencyKey: string; + entities: LocalUpstreamExportEntityInput[]; + warnings?: UpstreamTransferWarning[]; + featureFlags?: string[]; + maxEntitiesPerChunk?: number; +}): LocalUpstreamExportBundle { + const entities = input.entities.map((entity) => ({ + record: { + key: entity.key, + contentHash: normalizedContentHash(entity.body), + dependencies: entity.dependencies ?? [], + warnings: entity.warnings ?? [], + }, + body: entity.body, + conflictKeys: entity.conflictKeys, + })); + const chunksWithoutManifestHash = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? DEFAULT_MAX_ENTITIES_PER_CHUNK); + const manifestWithoutHash = { + schema: TRANSFER_SCHEMA, + source: input.source, + target: input.target, + runId: input.runId, + idempotencyKey: input.idempotencyKey, + generatedAt: new Date(0).toISOString(), + entityCount: entities.length, + perEntityTypeCounts: countEntityTypesForManifest(entities), + entities: entities.map((entity) => entity.record), + chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => chunk), + warnings: input.warnings ?? [], + featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(), + }; + const manifestHash = normalizedContentHash(manifestWithoutHash); + return { + manifest: { + ...manifestWithoutHash, + chunks: chunksWithoutManifestHash.map(({ payload: _payload, ...chunk }) => ({ ...chunk, manifestHash })), + manifestHash, + }, + entities, + chunks: chunksWithoutManifestHash, + }; +} + +function countEntityTypesForManifest(entities: LocalUpstreamExportEntity[]): Record { + const counts: Record = {}; + for (const entity of entities) { + const entityType = entity.record.key.sourceEntityType; + counts[entityType] = (counts[entityType] ?? 0) + 1; + } + return counts; +} + +function buildLocalChunks(entities: LocalUpstreamExportEntity[], maxEntitiesPerChunk: number): LocalUpstreamExportChunk[] { + if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { + throw new Error("maxEntitiesPerChunk must be a positive integer"); + } + if (entities.length === 0) return []; + + const groups: LocalUpstreamExportEntity[][] = []; + for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) { + groups.push(entities.slice(index, index + maxEntitiesPerChunk)); + } + + return groups.map((group, index) => { + const payload = { + entityKeys: group.map((entity) => entity.record.key), + }; + return { + chunkIndex: index, + totalChunks: groups.length, + byteLength: Buffer.byteLength(canonicalJson(payload)), + sha256: normalizedContentHash(payload), + payload, + }; + }); +} + +async function remoteGet(connection: ConnectionRow, path: string): Promise { + const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, { + method: "GET", + headers: await proofHeaders(connection, "GET", path), + }, REMOTE_FETCH_TIMEOUT_MS); + return parseRemoteResponse(response); +} + +async function remotePost(connection: ConnectionRow, path: string, body: unknown): Promise { + const response = await fetchWithTimeout(`${connection.targetOrigin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...await proofHeaders(connection, "POST", path), + }, + body: JSON.stringify(body), + }, REMOTE_FETCH_TIMEOUT_MS); + return parseRemoteResponse(response); +} + +async function proofHeaders(connection: ConnectionRow, method: string, pathAndSearch: string): Promise> { + if (!connection.accessToken || !connection.tokenId) { + throw badRequest("Cloud upstream connection is missing an import token"); + } + const accessToken = await unsealCloudUpstreamCredential(connection.accessToken); + const privateKeyPem = await unsealCloudUpstreamCredential(connection.privateKeyPem); + const timestamp = new Date().toISOString(); + const nonce = crypto.randomUUID(); + const payload = [ + method, + new URL(connection.targetOrigin).host.toLowerCase(), + pathAndSearch, + connection.tokenId, + connection.sourceInstanceId, + timestamp, + nonce, + ].join("\n"); + return { + Authorization: `Bearer ${accessToken}`, + "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId, + "X-Paperclip-Upstream-Proof-Timestamp": timestamp, + "X-Paperclip-Upstream-Proof-Nonce": nonce, + "X-Paperclip-Upstream-Proof-Signature": sign( + null, + Buffer.from(payload, "utf8"), + privateKeyPem, + ).toString("base64url"), + }; +} + +async function parseRemoteResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeParseJson(text) : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Cloud upstream request failed: ${response.status}`; + throw badRequest(message, parsed); + } + return parsed; +} + +async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit | undefined, timeoutMs: number): Promise { + return fetch(input, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); +} + +export async function sealCloudUpstreamCredential(value: string): Promise { + const prepared = await localEncryptedProvider.createSecret({ value }); + return `${CLOUD_CREDENTIAL_PREFIX}${JSON.stringify(prepared.material)}`; +} + +export async function unsealCloudUpstreamCredential(value: string): Promise { + if (!value.startsWith(CLOUD_CREDENTIAL_PREFIX)) return value; + const encoded = value.slice(CLOUD_CREDENTIAL_PREFIX.length); + const parsed = safeParseJson(encoded); + const material = optionalObject(parsed); + if (!material) { + throw badRequest("Invalid encrypted cloud upstream credential material"); + } + return localEncryptedProvider.resolveVersion({ + material, + externalRef: null, + }); +} + +export function cloudUpstreamRemoteFailureReport(error: unknown): { + error: string; + errorMessage?: string; + details?: unknown; +} { + const fallback = error instanceof Error ? error.message : String(error); + if (!(error instanceof HttpError)) { + return { error: fallback }; + } + const remote = remoteErrorBody(error.details); + return { + error: remote.error ?? error.message, + ...(remote.message ? { errorMessage: remote.message } : {}), + ...(error.details !== undefined ? { details: error.details } : {}), + }; +} + +function remoteErrorBody(details: unknown): { error?: string; message?: string } { + const record = optionalObject(details); + if (!record) return {}; + return { + error: optionalString(record.error) ?? undefined, + message: optionalString(record.message) ?? undefined, + }; +} + +async function postJson(url: string, body: unknown): Promise { + const response = await fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, DISCOVERY_FETCH_TIMEOUT_MS); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw badRequest((payload as { error?: string } | null)?.error ?? `Cloud upstream request failed: ${response.status}`); + } + return payload as T; +} + +function remoteRunIdFromResponse(value: unknown): string { + const record = asRecord(value); + const run = asRecord(record.run); + const id = optionalString(run.id); + if (!id) throw badRequest("Remote upstream importer did not return a run id"); + return id; +} + +function warningsFromRemote(value: unknown): CloudUpstreamWarning[] { + const record = asRecord(value); + const warnings = Array.isArray(record.warnings) ? record.warnings : []; + return warnings.map((warning, index): CloudUpstreamWarning => { + const item = asRecord(warning); + const code = optionalString(item.code) ?? `remote_warning_${index}`; + const severity = item.severity === "blocker" ? "blocker" : "warning"; + const message = optionalString(item.message) ?? optionalString(item.detail) ?? "Remote importer warning."; + return { + code, + severity, + title: titleFromCode(code), + detail: message, + }; + }); +} + +function conflictsFromRemote(value: unknown): CloudUpstreamConflict[] { + const record = asRecord(value); + const conflicts = Array.isArray(record.conflicts) ? record.conflicts : []; + return conflicts.map((conflict, index): CloudUpstreamConflict => { + const item = asRecord(conflict); + const source = asRecord(item.source); + return { + id: optionalString(item.id) ?? `remote-conflict-${index}`, + entityType: optionalString(item.entityType) ?? optionalString(source.sourceEntityType) ?? "entity", + sourceLabel: optionalString(item.sourceLabel) ?? optionalString(source.sourceNaturalKey) ?? optionalString(source.sourceEntityId) ?? "Source entity", + targetLabel: optionalString(item.targetLabel) ?? optionalString(item.targetEntityId) ?? "Cloud entity", + plannedAction: "blocked", + reason: optionalString(item.reason) ?? "Remote importer reported a conflict.", + }; + }); +} + +function eventsFromRemote(value: unknown): CloudUpstreamRunEvent[] { + const record = asRecord(value); + const events = Array.isArray(record.events) ? record.events : []; + return events.slice(-25).map((remote, index) => { + const item = asRecord(remote); + const action = optionalString(item.action) ?? "updated"; + return event( + optionalString(item.createdAt) ?? new Date().toISOString(), + "verify", + action.includes("created") ? "created" : "updated", + `Cloud importer ${action.replace(/_/g, " ")}${index >= 0 ? "." : "."}`, + ); + }); +} + +function safeRunEvents(value: unknown): CloudUpstreamRunEvent[] { + return Array.isArray(value) ? value as CloudUpstreamRunEvent[] : []; +} + +function assertActivationEntityType(value: string): asserts value is CloudUpstreamActivationEntityType { + if (value !== "agents" && value !== "routines" && value !== "monitors") { + throw badRequest("entityType must be agents, routines, or monitors"); + } +} + +function summaryCount(summary: unknown, key: CloudUpstreamActivationEntityType): number { + if (!Array.isArray(summary)) return 0; + const item = summary.find((entry) => asRecord(entry).key === key); + const count = asRecord(item).count; + return typeof count === "number" && Number.isFinite(count) ? count : 0; +} + +function activationChecklistFromReport(report: Record): Record { + const value = asRecord(report.activationChecklist); + const decisions: Record = {}; + for (const [key, decision] of Object.entries(value)) { + if (key !== "agents" && key !== "routines" && key !== "monitors") continue; + const item = asRecord(decision); + decisions[key] = { + entityType: key, + count: typeof item.count === "number" && Number.isFinite(item.count) ? item.count : 0, + status: item.status === "activated" ? "activated" : "paused", + activatedAt: optionalString(item.activatedAt), + }; + } + return decisions; +} + +function activationEntityLabel(entityType: CloudUpstreamActivationEntityType, count: number): string { + const singular = entityType === "agents" ? "agent" : entityType === "routines" ? "routine" : "monitor"; + return `${singular}${count === 1 ? "" : "s"}`; +} + +function mergeWarnings(base: CloudUpstreamWarning[], extra: CloudUpstreamWarning[]): CloudUpstreamWarning[] { + const byCode = new Map(); + for (const warning of [...base, ...extra]) byCode.set(warning.code, warning); + return [...byCode.values()]; +} + +function event( + at: string, + phase: CloudUpstreamRunEvent["phase"], + type: CloudUpstreamRunEvent["type"], + message: string, +): CloudUpstreamRunEvent { + return { + id: crypto.randomUUID(), + at, + phase, + type, + message, + }; +} + +function normalizedContentHash(value: unknown): NormalizedSha256 { + return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +function shortHash(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function sourceEntityKeyString(key: SourceEntityKey): string { + return [key.sourceInstanceId, key.sourceCompanyId, key.sourceEntityType, key.sourceEntityId] + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function titleFromCode(code: string): string { + return code + .replace(/_/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function objectField(value: Record, key: string): Record { + const field = value[key]; + if (!field || typeof field !== "object" || Array.isArray(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field as Record; +} + +function stringField(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function numberField(value: Record, key: string): number { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field)) { + throw badRequest(`Cloud upstream discovery missing ${key}`); + } + return field; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function optionalNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function optionalObject(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function cloudUpstreamTokenStatus(value: string): CloudUpstreamConnection["tokenStatus"] { + return value === "connected" || value === "expired" || value === "revoked" ? value : "pending"; +} + +function cloudUpstreamRunStatus(value: string): CloudUpstreamRun["status"] { + return value === "previewed" || value === "running" || value === "succeeded" || value === "failed" || value === "cancelled" + ? value + : "failed"; +} + +function cloudUpstreamStep(value: string): CloudUpstreamRun["activeStep"] { + return value === "connect" || value === "scan" || value === "preview" || value === "push" || value === "verify" || value === "activate" + ? value + : "push"; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 17905f11..e69abb9e 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -125,16 +125,18 @@ export function companyService(db: Db) { } function isIssuePrefixConflict(error: unknown) { - const constraint = typeof error === "object" && error !== null && "constraint" in error - ? (error as { constraint?: string }).constraint - : typeof error === "object" && error !== null && "constraint_name" in error - ? (error as { constraint_name?: string }).constraint_name - : undefined; - return typeof error === "object" - && error !== null - && "code" in error - && (error as { code?: string }).code === "23505" - && constraint === "companies_issue_prefix_idx"; + const seen = new Set(); + let current = error; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown }; + const constraint = maybe.constraint ?? maybe.constraint_name; + if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") { + return true; + } + current = maybe.cause; + } + return false; } async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) { @@ -265,7 +267,17 @@ export function companyService(db: Db) { remove: (id: string) => db.transaction(async (tx) => { // Delete from child tables in dependency order + const companyRunIds = await tx + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, id)); + await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); + if (companyRunIds.length > 0) { + await tx + .delete(heartbeatRunEvents) + .where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id))); + } await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id)); await tx.delete(activityLog).where(eq(activityLog.companyId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id)); diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 6e8513ee..593770ca 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -28,6 +28,7 @@ export function grantsForHumanRole( case "owner": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, @@ -36,6 +37,7 @@ export function grantsForHumanRole( case "admin": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 30e4c14f..f44ac3dc 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -4144,7 +4144,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (mode === "agent_safe" && options?.sourceCompanyId) { await access.copyActiveUserMemberships(options.sourceCompanyId, created.id); } else { - await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active"); + const ownerPrincipalId = actorUserId ?? "board"; + await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + created.id, + ownerPrincipalId, + "owner", + actorUserId ?? null, + ); } targetCompany = created; companyAction = "created"; diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 5c5ee747..dc322397 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -17,6 +17,20 @@ function isUniqueViolation(error: unknown): boolean { return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505"; } +function nextAvailableDocumentKey(sourceKey: string, existingKeys: string[]) { + const usedKeys = new Set(existingKeys); + for (let index = 2; index < 1000; index += 1) { + const suffix = `-${index}`; + const baseMaxLength = 64 - suffix.length; + const base = sourceKey.slice(0, baseMaxLength).replace(/[-_]+$/g, "") || "document"; + const candidate = `${base}${suffix}`; + if (!usedKeys.has(candidate) && issueDocumentKeySchema.safeParse(candidate).success) { + return candidate; + } + } + throw conflict("Unable to choose a new document key for locked document", { key: sourceKey }); +} + export function extractLegacyPlanBody(description: string | null | undefined) { if (!description) return null; const match = /\s*([\s\S]*?)\s*<\/plan>/i.exec(description); @@ -40,6 +54,9 @@ function mapIssueDocumentRow( createdByUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; + lockedAt: Date | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; createdAt: Date; updatedAt: Date; }, @@ -59,6 +76,9 @@ function mapIssueDocumentRow( createdByUserId: row.createdByUserId, updatedByAgentId: row.updatedByAgentId, updatedByUserId: row.updatedByUserId, + lockedAt: row.lockedAt, + lockedByAgentId: row.lockedByAgentId, + lockedByUserId: row.lockedByUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -78,6 +98,9 @@ const issueDocumentSelect = { createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, + lockedAt: documents.lockedAt, + lockedByAgentId: documents.lockedByAgentId, + lockedByUserId: documents.lockedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }; @@ -179,6 +202,7 @@ export function documentService(db: Db) { createdByAgentId?: string | null; createdByUserId?: string | null; createdByRunId?: string | null; + lockedDocumentStrategy?: "conflict" | "create_new_document"; }) => { const key = normalizeDocumentKey(input.key); const issue = await db @@ -188,8 +212,10 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); - try { - return await db.transaction(async (tx) => { + const maxAttempts = input.lockedDocumentStrategy === "create_new_document" ? 3 : 1; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await db.transaction(async (tx) => { const now = new Date(); const existing = await tx .select({ @@ -206,6 +232,9 @@ export function documentService(db: Db) { createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, + lockedAt: documents.lockedAt, + lockedByAgentId: documents.lockedByAgentId, + lockedByUserId: documents.lockedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) @@ -215,6 +244,102 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (existing) { + if (existing.lockedAt) { + if (input.lockedDocumentStrategy === "create_new_document") { + const issueDocumentKeys = await tx + .select({ key: issueDocuments.key }) + .from(issueDocuments) + .where(eq(issueDocuments.issueId, issue.id)); + const fallbackKey = nextAvailableDocumentKey(key, issueDocumentKeys.map((row) => row.key)); + + const [document] = await tx + .insert(documents) + .values({ + companyId: issue.companyId, + title: input.title ?? null, + format: input.format, + latestBody: input.body, + latestRevisionId: null, + latestRevisionNumber: 1, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + updatedByAgentId: input.createdByAgentId ?? null, + updatedByUserId: input.createdByUserId ?? null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: now, + updatedAt: now, + }) + .returning(); + + const [revision] = await tx + .insert(documentRevisions) + .values({ + companyId: issue.companyId, + documentId: document.id, + revisionNumber: 1, + title: input.title ?? null, + format: input.format, + body: input.body, + changeSummary: input.changeSummary ?? null, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + createdByRunId: input.createdByRunId ?? null, + createdAt: now, + }) + .returning(); + + await tx + .update(documents) + .set({ latestRevisionId: revision.id }) + .where(eq(documents.id, document.id)); + + await tx.insert(issueDocuments).values({ + companyId: issue.companyId, + issueId: issue.id, + documentId: document.id, + key: fallbackKey, + createdAt: now, + updatedAt: now, + }); + + return { + created: true as const, + redirectedFromLockedDocument: { + id: existing.id, + key: existing.key, + }, + document: { + id: document.id, + companyId: issue.companyId, + issueId: issue.id, + key: fallbackKey, + title: document.title, + format: document.format, + body: document.latestBody, + latestRevisionId: revision.id, + latestRevisionNumber: 1, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }, + }; + } + + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } + if (!input.baseRevisionId) { throw conflict("Document update requires baseRevisionId", { currentRevisionId: existing.latestRevisionId, @@ -274,6 +399,9 @@ export function documentService(db: Db) { latestRevisionNumber: nextRevisionNumber, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, + lockedAt: existing.lockedAt, + lockedByAgentId: existing.lockedByAgentId, + lockedByUserId: existing.lockedByUserId, updatedAt: now, }, }; @@ -296,6 +424,9 @@ export function documentService(db: Db) { createdByUserId: input.createdByUserId ?? null, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, createdAt: now, updatedAt: now, }) @@ -348,17 +479,26 @@ export function documentService(db: Db) { createdByUserId: document.createdByUserId, updatedByAgentId: document.updatedByAgentId, updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, }, }; - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw conflict("Document key already exists on this issue", { key }); + }); + } catch (error) { + if (isUniqueViolation(error)) { + if (input.lockedDocumentStrategy === "create_new_document" && attempt < maxAttempts - 1) { + continue; + } + throw conflict("Document key already exists on this issue", { key }); + } + throw error; } - throw error; } + + throw conflict("Unable to choose a new document key for locked document", { key }); }, restoreIssueDocumentRevision: async (input: { @@ -378,6 +518,13 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) throw notFound("Document not found"); + if (existing.lockedAt) { + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } const revision = await tx .select({ @@ -455,6 +602,105 @@ export function documentService(db: Db) { }); }, + lockIssueDocument: async (input: { + issueId: string; + key: string; + lockedByAgentId?: string | null; + lockedByUserId?: string | null; + }) => { + const key = normalizeDocumentKey(input.key); + return db.transaction(async (tx) => { + const existing = await tx + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, key))) + .then((rows) => rows[0] ?? null); + + if (!existing) throw notFound("Document not found"); + if (existing.lockedAt) { + return { + changed: false as const, + document: mapIssueDocumentRow(existing, true), + }; + } + + const now = new Date(); + await tx + .update(documents) + .set({ + lockedAt: now, + lockedByAgentId: input.lockedByAgentId ?? null, + lockedByUserId: input.lockedByUserId ?? null, + updatedAt: now, + }) + .where(eq(documents.id, existing.id)); + + await tx + .update(issueDocuments) + .set({ updatedAt: now }) + .where(eq(issueDocuments.documentId, existing.id)); + + return { + changed: true as const, + document: { + ...mapIssueDocumentRow(existing, true), + lockedAt: now, + lockedByAgentId: input.lockedByAgentId ?? null, + lockedByUserId: input.lockedByUserId ?? null, + updatedAt: now, + }, + }; + }); + }, + + unlockIssueDocument: async (issueId: string, rawKey: string) => { + const key = normalizeDocumentKey(rawKey); + return db.transaction(async (tx) => { + const existing = await tx + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) + .then((rows) => rows[0] ?? null); + + if (!existing) throw notFound("Document not found"); + if (!existing.lockedAt) { + return { + changed: false as const, + document: mapIssueDocumentRow(existing, true), + }; + } + + const now = new Date(); + await tx + .update(documents) + .set({ + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }) + .where(eq(documents.id, existing.id)); + + await tx + .update(issueDocuments) + .set({ updatedAt: now }) + .where(eq(issueDocuments.documentId, existing.id)); + + return { + changed: true as const, + document: { + ...mapIssueDocumentRow(existing, true), + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }, + }; + }); + }, + deleteIssueDocument: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); return db.transaction(async (tx) => { @@ -466,6 +712,13 @@ export function documentService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) return null; + if (existing.lockedAt) { + throw conflict("Document is locked", { + key: existing.key, + documentId: existing.id, + lockedAt: existing.lockedAt, + }); + } await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id)); await tx.delete(documents).where(eq(documents.id, existing.id)); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index e5fa36a1..ee5eb48a 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { @@ -344,12 +344,18 @@ function toExecutionWorkspace( }; } -function toExecutionWorkspaceSummary(row: Pick): ExecutionWorkspaceSummary { +function toExecutionWorkspaceSummary( + row: Pick, +): ExecutionWorkspaceSummary { return { id: row.id, name: row.name, mode: row.mode as ExecutionWorkspaceSummary["mode"], + status: row.status as ExecutionWorkspaceSummary["status"], + cwd: row.cwd ?? null, + branchName: row.branchName ?? null, projectWorkspaceId: row.projectWorkspaceId ?? null, + lastUsedAt: row.lastUsedAt, }; } @@ -412,6 +418,8 @@ export function executionWorkspaceService(db: Db) { } if (filters?.reuseEligible) { conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); + conditions.push(isNull(executionWorkspaces.closedAt)); + conditions.push(inArray(executionWorkspaces.mode, ["isolated_workspace", "operator_branch", "adapter_managed", "cloud_sandbox"])); } return conditions; } @@ -452,7 +460,11 @@ export function executionWorkspaceService(db: Db) { id: executionWorkspaces.id, name: executionWorkspaces.name, mode: executionWorkspaces.mode, + status: executionWorkspaces.status, + cwd: executionWorkspaces.cwd, + branchName: executionWorkspaces.branchName, projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + lastUsedAt: executionWorkspaces.lastUsedAt, }) .from(executionWorkspaces) .where(and(...conditions)) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3fa574b7..8c34e992 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,6 +18,7 @@ import { type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, + type RoutineRevisionSnapshotV1, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -40,6 +41,9 @@ import { issueWorkProducts, projects, projectWorkspaces, + routineRevisions, + routineRuns, + routines, workspaceOperations, } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; @@ -159,7 +163,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { extractSkillMentionIds } from "@paperclipai/shared"; +import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { environmentRuntimeService } from "./environment-runtime.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; @@ -327,19 +331,44 @@ type RuntimeConfigSecretResolver = Pick< "resolveAdapterConfigForRuntime" | "resolveEnvBindings" >; +function isPaperclipRuntimeEnvKey(key: string) { + return key.startsWith("PAPERCLIP_"); +} + +function stripPaperclipRuntimeEnvBindings(envValue: unknown): Record | null { + const record = parseObject(envValue); + const filtered = Object.fromEntries( + Object.entries(record).filter(([key]) => !isPaperclipRuntimeEnvKey(key)), + ); + return Object.keys(filtered).length > 0 ? filtered : null; +} + +function stripPaperclipRuntimeEnvFromAdapterConfig(config: Record): Record { + if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; + return { + ...config, + env: stripPaperclipRuntimeEnvBindings(config.env) ?? {}, + }; +} + export async function resolveExecutionRunAdapterConfig(input: { companyId: string; agentId?: string | null; issueId?: string | null; heartbeatRunId?: string | null; projectId?: string | null; + routineId?: string | null; executionRunConfig: Record; projectEnv: unknown; + routineEnv?: unknown; secretsSvc: RuntimeConfigSecretResolver; }) { + const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig); + const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv); + const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv); const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime( input.companyId, - input.executionRunConfig, + executionRunConfig, input.agentId ? { consumerType: "agent", @@ -351,10 +380,10 @@ export async function resolveExecutionRunAdapterConfig(input: { } : undefined, ); - const projectEnvResolution = input.projectEnv + const projectEnvResolution = projectEnv ? await input.secretsSvc.resolveEnvBindings( input.companyId, - input.projectEnv, + projectEnv, input.projectId ? { consumerType: "project", @@ -376,10 +405,39 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const routineEnvResolution = routineEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + routineEnv, + input.routineId + ? { + consumerType: "routine", + consumerId: input.routineId, + actorType: "agent", + actorId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(routineEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...routineEnvResolution.env, + }; + for (const key of routineEnvResolution.secretKeys) { + secretKeys.add(key); + } + } return { resolvedConfig, secretKeys, - secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])], + secretManifest: [ + ...(manifest ?? []), + ...(projectEnvResolution.manifest ?? []), + ...(routineEnvResolution.manifest ?? []), + ], }; } @@ -942,7 +1000,7 @@ function redactInlineBase64ImageData(chunk: string) { } export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) { - const normalized = redactInlineBase64ImageData(chunk); + const normalized = redactSensitiveText(redactInlineBase64ImageData(chunk)); if (normalized.length <= maxChars) return normalized; const headChars = Math.max(0, Math.floor(maxChars * 0.6)); @@ -1777,7 +1835,7 @@ function enrichWakeContextSnapshot(input: { payload: Record | null; }) { const { contextSnapshot, reason, source, triggerDetail, payload } = input; - const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]); + const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]) ?? readNonEmptyString(payload?.["taskId"]); const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); @@ -2433,12 +2491,67 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionWorkspaceSettings: issues.executionWorkspaceSettings, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); } + async function getRoutineEnvForExecutionIssue( + companyId: string, + issueContext: Awaited> | null, + ) { + if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) { + return { routineId: null, env: null }; + } + + const routineRun = issueContext.originRunId + ? await db + .select({ + routineRevisionId: routineRuns.routineRevisionId, + }) + .from(routineRuns) + .where( + and( + eq(routineRuns.id, issueContext.originRunId), + eq(routineRuns.companyId, companyId), + eq(routineRuns.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + if (routineRun?.routineRevisionId) { + const revision = await db + .select({ + snapshot: routineRevisions.snapshot, + }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.id, routineRun.routineRevisionId), + eq(routineRevisions.companyId, companyId), + eq(routineRevisions.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null); + const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined; + if (snapshot?.version === 1) { + return { routineId: issueContext.originId, env: snapshot.routine.env ?? null }; + } + } + + const routine = await db + .select({ env: routines.env }) + .from(routines) + .where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return { routineId: issueContext.originId, env: routine?.env ?? null }; + } + async function getRuntimeState(agentId: string) { return db .select() @@ -2672,7 +2785,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) projectId: input.claimed.projectId, goalId: input.claimed.goalId, assigneeAgentId: input.claimed.assigneeAgentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(), + assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery, originId: input.claimed.id, originFingerprint: `issue_monitor:${input.clearReason}`, @@ -2686,7 +2799,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) triggerDetail: "system", reason: "issue_monitor_recovery_issue", idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`, - payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }), + payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }, "status_only"), requestedByActorType: input.actorType, requestedByActorId: input.actorId, contextSnapshot: withRecoveryModelProfileHint({ @@ -2694,7 +2807,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) sourceIssueId: input.claimed.id, source: "issue.monitor.recovery_issue", wakeReason: "issue_monitor_recovery_issue", - }), + }, "status_only"), }); } @@ -2755,7 +2868,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) serviceName: input.monitor?.serviceName ?? null, timeoutAt: input.monitor?.timeoutAt ?? null, maxAttempts: input.monitor?.maxAttempts ?? null, - }), + }, "status_only"), requestedByActorType: input.actorType, requestedByActorId: input.actorId, contextSnapshot: withRecoveryModelProfileHint({ @@ -2768,7 +2881,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) serviceName: input.monitor?.serviceName ?? null, timeoutAt: input.monitor?.timeoutAt ?? null, maxAttempts: input.monitor?.maxAttempts ?? null, - }), + }, "status_only"), }); await logActivity(db, { @@ -3419,7 +3532,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null }, ): Promise { - const issueId = readNonEmptyString(context.issueId); + const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const contextProjectId = readNonEmptyString(context.projectId); const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); const issueProjectRef = issueId @@ -4422,7 +4535,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) wakeReason: "missing_issue_comment", retryReason: "missing_issue_comment", missingIssueCommentForRunId: run.id, - }); + }, "status_only"); const now = new Date(); const retryRun = await db.transaction(async (tx) => { @@ -4449,7 +4562,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, retryOfRunId: run.id, retryReason: "missing_issue_comment", - }), + }, "status_only"), status: "queued", requestedByActorType: "system", requestedByActorId: null, @@ -4642,7 +4755,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) retryOfRunId: run.id, wakeReason: "process_lost_retry", retryReason: "process_lost", - }); + }, "normal_model"); const queued = await db.transaction(async (tx) => { const wakeupRequest = await tx @@ -4656,7 +4769,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: withRecoveryModelProfileHint({ ...(issueId ? { issueId } : {}), retryOfRunId: run.id, - }), + }, "normal_model"), status: "queued", requestedByActorType: "system", requestedByActorId: null, @@ -5209,7 +5322,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) scheduledRetryAt: schedule.dueAt.toISOString(), ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), - }); + }, "normal_model"); const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON ? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` : null; @@ -5379,7 +5492,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) scheduledRetryAt: schedule.dueAt.toISOString(), ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), - }), + }, "normal_model"), status: "queued", requestedByActorType: "system", requestedByActorId: null, @@ -6870,6 +6983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; + const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext); const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), isolatedWorkspacesEnabled, @@ -7074,8 +7188,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, heartbeatRunId: run.id, projectId: projectContext?.id ?? null, + routineId: routineEnvContext.routineId, executionRunConfig, projectEnv: projectContext?.env ?? null, + routineEnv: routineEnvContext.env, secretsSvc, }); if (secretManifest.length > 0) { @@ -8446,7 +8562,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: withRecoveryModelProfileHint({ issueId: issue.id, retryOfRunId: run.id, - }), + }, "normal_model"), status: "queued", requestedByActorType: "system", requestedByActorId: null, @@ -8471,7 +8587,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) retryReason, source: recoverySource, retryOfRunId: run.id, - }), + }, "normal_model"), sessionIdBefore: recoverySessionBefore, retryOfRunId: run.id, updatedAt: now, @@ -8606,11 +8722,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) let projectId = readNonEmptyString(enrichedContextSnapshot.projectId); if (!projectId && issueId) { - projectId = await db - .select({ projectId: issues.projectId }) + // Look up by either UUID or identifier (e.g. "ENV-13"), but always scope + // by companyId so a row from another tenant can never be returned even + // when identifiers collide across companies. Guard the UUID arm because + // issues.id is a Postgres uuid column — passing "ENV-13" into eq(issues.id, …) + // would fail with an invalid-input-syntax cast error before the OR is + // evaluated. + const lookupIsUuid = isUuidLike(issueId); + const idMatch = lookupIsUuid + ? or(eq(issues.id, issueId), eq(issues.identifier, issueId.toUpperCase())) + : eq(issues.identifier, issueId.toUpperCase()); + const resolvedIssue = await db + .select({ id: issues.id, projectId: issues.projectId }) .from(issues) - .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) - .then((rows) => rows[0]?.projectId ?? null); + .where(and(eq(issues.companyId, agent.companyId), idMatch)) + .then((rows) => rows[0] ?? null); + if (resolvedIssue) { + projectId = resolvedIssue.projectId ?? null; + // Canonicalize context to the UUID so downstream lookups always use UUID + if (resolvedIssue.id !== issueId) { + issueId = resolvedIssue.id; + enrichedContextSnapshot.issueId = issueId; + if (readNonEmptyString(enrichedContextSnapshot.taskId)) { + enrichedContextSnapshot.taskId = issueId; + } + } + } + } + // Propagate projectId into context so resolveWorkspaceForRun can bind the + // project workspace even when context.projectId wasn't set by the caller. + if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) { + enrichedContextSnapshot.projectId = projectId; } const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2e7c6bcd..fc5b5ab5 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -42,10 +42,25 @@ export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recover export { dashboardService } from "./dashboard.js"; export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js"; +export { resourceMembershipService, type ResourceMembershipPolicyHook } from "./resource-memberships.js"; export { inboxDismissalService } from "./inbox-dismissals.js"; export { accessService } from "./access.js"; +export { + backfillPrincipalAccessCompatibility, + ensureHumanRoleDefaultGrants, + insertMissingPrincipalGrants, + type PrincipalAccessCompatibilityBackfillStats, +} from "./principal-access-compatibility.js"; +export { authorizationService } from "./authorization.js"; +export type { + AuthorizationAction, + AuthorizationActor, + AuthorizationDecision, + AuthorizationResource, +} from "./authorization.js"; export { boardAuthService } from "./board-auth.js"; export { instanceSettingsService } from "./instance-settings.js"; +export { cloudUpstreamService, reconcileCloudUpstreamRunsOnStartup } from "./cloud-upstreams.js"; export { companyPortabilityService } from "./company-portability.js"; export { environmentService } from "./environments.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index c447a920..8b02f57c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -15,9 +15,11 @@ import { import { eq } from "drizzle-orm"; const DEFAULT_SINGLETON_KEY = "default"; +const instanceGeneralSettingsStorageSchema = instanceGeneralSettingsSchema.strip(); +const instanceExperimentalSettingsStorageSchema = instanceExperimentalSettingsSchema.strip(); function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { - const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {}); + const parsed = instanceGeneralSettingsStorageSchema.safeParse(raw ?? {}); if (parsed.success) { return { censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, @@ -35,12 +37,13 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { }; } -function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings { - const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {}); +export function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings { + const parsed = instanceExperimentalSettingsStorageSchema.safeParse(raw ?? {}); if (parsed.success) { return { enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + enableCloudSync: parsed.data.enableCloudSync ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -51,6 +54,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin return { enableEnvironments: false, enableIsolatedWorkspaces: false, + enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 80b31c11..6fc377c2 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from "node:util"; -import { and, asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documents, @@ -158,6 +158,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: { return true; } +function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) { + return interaction.payload.supersedeOnUserComment === true; +} + +function isCommentAtOrAfterInteraction(args: { + commentCreatedAt: Date | string; + interactionCreatedAt: Date | string; +}) { + const commentCreatedAtMs = new Date(args.commentCreatedAt).getTime(); + const interactionCreatedAtMs = new Date(args.interactionCreatedAt).getTime(); + if (!Number.isFinite(commentCreatedAtMs) || !Number.isFinite(interactionCreatedAtMs)) return false; + return commentCreatedAtMs >= interactionCreatedAtMs; +} + function buildTaskCreationOrder(tasks: ReadonlyArray) { const taskByClientKey = new Map(tasks.map((task) => [task.clientKey, task] as const)); const ordered: Array = []; @@ -967,7 +981,7 @@ export function issueThreadInteractionService(db: Db) { expireRequestConfirmationsSupersededByComment: async ( issue: { id: string; companyId: string }, - comment: { id: string; authorUserId?: string | null }, + comment: { id: string; createdAt: Date | string; authorUserId?: string | null }, actor: InteractionActor, ) => { if (!comment.authorUserId) return []; @@ -984,7 +998,13 @@ export function issueThreadInteractionService(db: Db) { const superseded = rows.filter((row) => { const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; - return interaction.payload.supersedeOnUserComment === true; + return ( + shouldSupersedeRequestConfirmationOnUserComment(interaction) + && isCommentAtOrAfterInteraction({ + commentCreatedAt: comment.createdAt, + interactionCreatedAt: row.createdAt, + }) + ); }); if (superseded.length === 0) return []; @@ -1020,6 +1040,91 @@ export function issueThreadInteractionService(db: Db) { return expired; }, + expireRequestConfirmationsSupersededByHistoricalComments: async ( + issue: { id: string; companyId: string }, + ) => { + const [rows, comments] = await Promise.all([ + db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + )), + db + .select() + .from(issueComments) + .where(and( + eq(issueComments.companyId, issue.companyId), + eq(issueComments.issueId, issue.id), + isNotNull(issueComments.authorUserId), + )) + .orderBy(asc(issueComments.createdAt)), + ]); + + if (rows.length === 0 || comments.length === 0) return []; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + const supersededByComment = new Map< + string, + { + comment: (typeof comments)[number]; + rowIds: string[]; + } + >(); + for (const row of rows) { + const interaction = hydrateInteraction(row) as RequestConfirmationInteraction; + if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue; + + const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({ + commentCreatedAt: comment.createdAt, + interactionCreatedAt: row.createdAt, + })); + if (!supersedingComment) continue; + + const group = supersededByComment.get(supersedingComment.id); + if (group) { + group.rowIds.push(row.id); + } else { + supersededByComment.set(supersedingComment.id, { + comment: supersedingComment, + rowIds: [row.id], + }); + } + } + + for (const { comment, rowIds } of supersededByComment.values()) { + const updatedRows = await db + .update(issueThreadInteractions) + .set({ + status: "expired", + result: { + version: 1, + outcome: "superseded_by_comment", + commentId: comment.id, + }, + resolvedByAgentId: null, + resolvedByUserId: comment.authorUserId, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + inArray(issueThreadInteractions.id, rowIds), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + expired.push(...updatedRows.map(hydrateInteraction)); + } + + if (expired.length > 0) { + await touchIssue(db, issue.id); + } + return expired; + }, + expireStaleRequestConfirmationsForIssueDocument: async ( issue: { id: string; companyId: string }, document: { id: string; key: string; latestRevisionId?: string | null; latestRevisionNumber?: number | null } | null, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e0a3f1a5..a61d7f2b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -50,7 +50,8 @@ import { isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, } from "@paperclipai/shared"; -import { conflict, notFound, unprocessable } from "../errors.js"; +import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { parseObject } from "../adapters/utils.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -72,7 +73,10 @@ import { issueTreeControlService, type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; -import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; +import { + parseIssueGraphLivenessIncidentKey, + RECOVERY_ORIGIN_KINDS, +} from "./recovery/origins.js"; import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -235,6 +239,8 @@ export interface IssueFilters { q?: string; limit?: number; offset?: number; + sortField?: "updated"; + sortDir?: "asc" | "desc"; } type IssueRow = typeof issues.$inferSelect; @@ -677,14 +683,25 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) { `; } +const LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS = [ + "plugin:paperclipai.content-machine:case", + "plugin:paperclipai.content-machine:evaluation", + "plugin:paperclipai.content-machine:source-sync", +] as const; + function nonPluginOperationIssueCondition() { - return sql`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`; + return sql`NOT ( + ${issues.originKind} LIKE 'plugin:%:operation' + OR ${issues.originKind} LIKE 'plugin:%:operation:%' + OR ${inArray(issues.originKind, LEGACY_PLUGIN_OPERATION_ORIGIN_KINDS)} + )`; } function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { return Boolean( filters?.includePluginOperations || filters?.originKind || + filters?.originKindPrefix || filters?.originId || filters?.projectId, ); @@ -778,6 +795,43 @@ function latestIssueActivityAt(...values: Array> { const map = new Map(); if (issueIds.length === 0) return map; @@ -2804,6 +2858,7 @@ export function issueService(db: Db) { } async function readRunLogText(run: { + runId?: string | null; logStore: string | null; logRef: string | null; logBytes: number | null; @@ -2817,19 +2872,30 @@ export function issueService(db: Db) { let content = ""; let nextOffset: number | undefined = 0; - while (nextOffset !== undefined) { - const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); - if (remainingBytes <= 0) break; - const chunk = await store.read( - { store: "local_file", logRef: run.logRef }, - { - offset, - limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), - }, - ); - content += chunk.content; - nextOffset = chunk.nextOffset; - offset = chunk.nextOffset ?? 0; + try { + while (nextOffset !== undefined) { + const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); + if (remainingBytes <= 0) break; + const chunk = await store.read( + { store: "local_file", logRef: run.logRef }, + { + offset, + limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), + }, + ); + content += chunk.content; + nextOffset = chunk.nextOffset; + offset = chunk.nextOffset ?? 0; + } + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + logger.warn( + { err, runId: run.runId ?? undefined, logRef: run.logRef }, + "missing heartbeat run log while deriving issue comment metadata", + ); + return content; + } + throw err; } return content; @@ -3505,18 +3571,17 @@ export function issueService(db: Db) { ELSE 6 END `; - const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db .select(issueListSelect) .from(issues) .where(and(...conditions)) - .orderBy( - hasSearch ? asc(searchOrder) : asc(priorityOrder), - asc(priorityOrder), - desc(canonicalLastActivityAt), - desc(issues.updatedAt), - desc(issues.id), - ); + .orderBy(...issueListOrderBy(companyId, { + hasSearch, + priorityOrder, + searchOrder, + sortField: filters?.sortField, + sortDir: filters?.sortDir, + })); const pageQuery = offset > 0 ? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset)) : (limit === undefined ? baseQuery : baseQuery.limit(limit)); @@ -4502,6 +4567,25 @@ export function issueService(db: Db) { } } const [enriched] = await withIssueLabels(tx, [updated]); + if ( + (issueData.status === "done" || issueData.status === "cancelled") && + existing.status !== issueData.status && + existing.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation + ) { + const parsedIncident = parseIssueGraphLivenessIncidentKey(existing.originId); + if (parsedIncident?.issueId && parsedIncident.companyId === existing.companyId) { + await tx + .delete(issueRelations) + .where( + and( + eq(issueRelations.companyId, existing.companyId), + eq(issueRelations.issueId, existing.id), + eq(issueRelations.relatedIssueId, parsedIncident.issueId), + eq(issueRelations.type, "blocks"), + ), + ); + } + } return enriched; }; diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index ef54d61d..f8b47866 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -55,6 +55,7 @@ const OPERATION_CAPABILITIES: Record = { "routines.managed.reset": ["routines.managed"], "project.workspaces.list": ["project.workspaces.read"], "project.workspaces.get": ["project.workspaces.read"], + "execution.workspaces.get": ["execution.workspaces.read"], "issues.list": ["issues.read"], "issues.get": ["issues.read"], "issues.relations.get": ["issue.relations.read"], @@ -148,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record = { commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", + companySettingsPage: "instance.settings.register", routeSidebar: "ui.sidebar.register", }; diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts index e6811702..d6b4cdda 100644 --- a/server/src/services/plugin-database.ts +++ b/server/src/services/plugin-database.ts @@ -19,6 +19,9 @@ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const MAX_POSTGRES_IDENTIFIER_LENGTH = 63; type SqlRef = { schema: string; table: string; keyword: string }; +type QualifiedRefPattern = + | { pattern: RegExp; groups: "keyword-schema-table" } + | { pattern: RegExp; groups: "schema-table"; keyword: string }; export type PluginDatabaseRuntimeResult> = { rows?: T[]; @@ -123,14 +126,29 @@ function normaliseSql(input: string): string { function extractQualifiedRefs(statement: string): SqlRef[] { const refs: SqlRef[] = []; - const patterns = [ - /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, - /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + const patterns: QualifiedRefPattern[] = [ + { + pattern: /\b(from|join|references|into|update)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "keyword-schema-table", + }, + { + pattern: /\b(alter\s+table|create\s+table|create\s+view|drop\s+table|truncate\s+table)\s+(?:if\s+(?:not\s+)?exists\s+)?"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "keyword-schema-table", + }, + { + pattern: /\bcreate\s+(?:unique\s+)?index(?:\s+concurrently)?\s+(?:if\s+not\s+exists\s+)?"?[A-Za-z_][A-Za-z0-9_]*"?\s+on\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\."?([A-Za-z_][A-Za-z0-9_]*)"?/gi, + groups: "schema-table", + keyword: "create index", + }, ]; - for (const pattern of patterns) { + for (const { pattern, ...mapping } of patterns) { for (const match of statement.matchAll(pattern)) { - refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! }); + if (mapping.groups === "keyword-schema-table") { + refs.push({ keyword: match[1]!.toLowerCase(), schema: match[2]!, table: match[3]! }); + } else { + refs.push({ keyword: mapping.keyword, schema: match[1]!, table: match[2]! }); + } } } return refs; @@ -182,9 +200,16 @@ export function validatePluginMigrationStatement( throw new Error("Destructive plugin migrations are not allowed in Phase 1"); } - const ddlAllowed = /^(create|alter|comment)\b/.test(normalized); - if (!ddlAllowed) { - throw new Error("Plugin migrations may contain DDL statements only"); + if (/\bdelete\s+from\b/.test(normalized)) { + throw new Error("Plugin migrations cannot delete data"); + } + + const ddlOrBackfillAllowed = + /^(create|alter|comment)\b/.test(normalized) || + /^(insert\s+into|update)\b/.test(normalized) || + (normalized.startsWith("with ") && /\b(insert\s+into|update)\b/.test(normalized)); + if (!ddlOrBackfillAllowed) { + throw new Error("Plugin migrations may contain DDL or namespace-scoped backfill statements only"); } const refs = extractQualifiedRefs(statement); @@ -192,6 +217,21 @@ export function validatePluginMigrationStatement( throw new Error("Plugin migration objects must use fully qualified schema names"); } + const objectRefKeywords = new Set([ + "alter table", + "create index", + "create table", + "create view", + "drop table", + "into", + "truncate table", + "update", + ]); + const hasQualifiedObjectRef = refs.some((ref) => objectRefKeywords.has(ref.keyword)); + if (!hasQualifiedObjectRef && !normalized.startsWith("comment ")) { + throw new Error("Plugin migration objects must use fully qualified schema names"); + } + const allowedCoreReadTables = new Set(coreReadTables); for (const ref of refs) { if (ref.schema === namespace) continue; diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index aa5d9278..0bc00d1d 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -182,7 +182,7 @@ export async function probePluginEnvironmentDriver(input: { companyId: input.companyId, environmentId: input.environmentId, config: input.config.driverConfig, - }); + }, 120_000); return { ok: result.ok, @@ -227,7 +227,7 @@ export async function probePluginSandboxProviderDriver(input: { companyId: input.companyId, environmentId: input.environmentId, config: driverConfig, - }); + }, 120_000); return { ok: result.ok, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 3d4c8947..7c2a9156 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1,14 +1,18 @@ import type { Db } from "@paperclipai/db"; import { + activityLog, agentTaskSessions as agentTaskSessionsTable, agents as agentsTable, budgetIncidents, costEvents, heartbeatRuns, + invites, issues as issuesTable, pluginLogs, + principalPermissionGrants, + projects as projectsTable, } from "@paperclipai/db"; -import { eq, and, like, desc, inArray, sql } from "drizzle-orm"; +import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm"; import type { HostServices, Company, @@ -20,12 +24,14 @@ import type { IssueComment, PluginIssueAssigneeSummary, PluginIssueOrchestrationSummary, + PluginExecutionWorkspaceMetadata, } from "@paperclipai/plugin-sdk"; -import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; +import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; +import { executionWorkspaceService } from "./execution-workspaces.js"; import { issueService } from "./issues.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { goalService } from "./goals.js"; @@ -34,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js"; import { budgetService } from "./budgets.js"; import { issueApprovalService } from "./issue-approvals.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import path from "node:path"; -import { activityService } from "./activity.js"; -import { costService } from "./costs.js"; -import { assetService } from "./assets.js"; import { pluginRegistryService } from "./plugin-registry.js"; import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; @@ -69,6 +72,9 @@ import { request as httpsRequest } from "node:https"; import { isIP } from "node:net"; import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; +import { accessService } from "./access.js"; +import { authorizationService, type AuthorizationActor } from "./authorization.js"; +import { sanitizeRecord } from "../redaction.js"; // --------------------------------------------------------------------------- // SSRF protection for plugin HTTP fetch @@ -520,14 +526,14 @@ export function buildHostServices( pluginWorkerManager: options.pluginWorkerManager, }); const projects = projectService(db); + const executionWorkspaces = executionWorkspaceService(db); const issues = issueService(db); const documents = documentService(db); const goals = goalService(db); - const activity = activityService(db); - const costs = costService(db); + const access = accessService(db); + const authorization = authorizationService(db); const budgets = budgetService(db); const issueApprovals = issueApprovalService(db); - const assets = assetService(db); const scopedBus = eventBus.forPlugin(pluginKey); // Track active session event subscriptions for cleanup @@ -559,6 +565,17 @@ export function buildHostServices( return rows.slice(offset, offset + limit); }; + const authorizationAuditDecisionCondition = (decisionFilter: string) => { + const conditions = [ + sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`, + decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined, + decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined, + decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined, + decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + return sql`(${sql.join(conditions, sql` OR `)})`; + }; + /** * Plugins are instance-wide in the current runtime. Company IDs are still * required for company-scoped data access, but there is no per-company @@ -588,6 +605,35 @@ export function buildHostServices( companyId: string, ): record is T => Boolean(record && record.companyId === companyId); + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + + const readProviderMetadata = (metadata: Record | null | undefined) => { + if (!isRecord(metadata)) return null; + if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata }; + const rebuild = metadata.rebuild; + if (!isRecord(rebuild)) return null; + const rebuildMetadata = rebuild.metadata; + if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null; + return { ...rebuildMetadata.providerMetadata }; + }; + + const toPluginExecutionWorkspaceMetadata = ( + workspace: NonNullable>>, + ): PluginExecutionWorkspaceMetadata => ({ + id: workspace.id, + companyId: workspace.companyId, + projectId: workspace.projectId, + projectWorkspaceId: workspace.projectWorkspaceId, + path: workspace.cwd ?? workspace.providerRef, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + baseRef: workspace.baseRef, + branchName: workspace.branchName, + providerType: workspace.providerType, + providerMetadata: readProviderMetadata(workspace.metadata), + }); + const requireInCompany = ( entityName: string, record: T | null | undefined, @@ -809,6 +855,202 @@ export function buildHostServices( })); }; + const INVITE_TOKEN_PREFIX = "pcp_invite_"; + const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; + const INVITE_TOKEN_SUFFIX_LENGTH = 8; + const INVITE_TOKEN_MAX_RETRIES = 5; + const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000; + + const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); + + const createInviteToken = () => { + const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); + let suffix = ""; + for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) { + suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length]; + } + return `${INVITE_TOKEN_PREFIX}${suffix}`; + }; + + const isInviteTokenHashCollisionError = (error: unknown) => { + const candidates = [ + error, + (error as { cause?: unknown } | null)?.cause ?? null, + ]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null; + const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : ""; + const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null; + if (code !== "23505") continue; + if (constraint === "invites_token_hash_unique_idx") return true; + if (message.includes("invites_token_hash_unique_idx")) return true; + } + return false; + }; + + const inviteState = (invite: typeof invites.$inferSelect) => { + if (invite.revokedAt) return "revoked" as const; + if (invite.acceptedAt) return "accepted" as const; + if (invite.expiresAt <= new Date()) return "expired" as const; + return "active" as const; + }; + + const redactInvite = (invite: typeof invites.$inferSelect) => { + const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite; + return { + ...safeInvite, + allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType, + defaultsPayload: defaultsPayload && typeof defaultsPayload === "object" + ? sanitizeRecord(defaultsPayload) + : defaultsPayload ?? null, + state: inviteState(invite), + }; + }; + + const inviteStateWhereClause = (state: unknown) => { + const now = new Date(); + switch (state) { + case "active": + return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now)); + case "accepted": + return isNotNull(invites.acceptedAt); + case "expired": + return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now)); + case "revoked": + return isNotNull(invites.revokedAt); + default: + return undefined; + } + }; + + const mergeInviteDefaults = (defaultsPayload: Record | null | undefined, agentMessage: string | null, humanRole: string | null) => { + const defaults = defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; + if (humanRole) { + defaults.human = { + ...(typeof defaults.human === "object" && defaults.human !== null ? defaults.human as Record : {}), + role: humanRole, + }; + } + if (agentMessage) { + defaults.agent = { + ...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record : {}), + message: agentMessage, + }; + } + return sanitizeRecord(defaults); + }; + + const redactGrant = (grant: typeof principalPermissionGrants.$inferSelect) => ({ + ...grant, + principalType: grant.principalType as PrincipalType, + permissionKey: grant.permissionKey as PermissionKey, + scope: grant.scope && typeof grant.scope === "object" ? sanitizeRecord(grant.scope) : grant.scope ?? null, + }); + + const loadPluginMember = async (companyId: string, memberId: string) => { + const member = await access.getMemberById(companyId, memberId); + if (!member) return null; + const grants = await access.listPrincipalGrants( + companyId, + member.principalType as PrincipalType, + member.principalId, + ); + return { + ...member, + principalType: member.principalType as PrincipalType, + status: member.status as "pending" | "active" | "suspended" | "archived", + grants: grants.map(redactGrant), + }; + }; + + const pluginAssignmentActor = (actor: { + type: "agent" | "board"; + agentId?: string | null; + companyId?: string | null; + userId?: string | null; + companyIds?: string[]; + }): AuthorizationActor => { + if (actor.type === "agent") { + return { + type: "agent", + agentId: actor.agentId ?? null, + companyId: actor.companyId ?? null, + source: "agent_key", + }; + } + return { + type: "board", + userId: actor.userId ?? null, + companyIds: Array.isArray(actor.companyIds) ? actor.companyIds : [], + source: "session", + }; + }; + + const policyPathForResource = (resourceType: "company" | "agent" | "project" | "issue") => { + switch (resourceType) { + case "agent": + return { table: "agent" as const }; + case "project": + return { table: "project" as const }; + case "issue": + return { table: "issue" as const }; + case "company": + return { table: "company" as const }; + } + }; + + const readAuthorizationPolicy = async (companyId: string, resourceType: "company" | "agent" | "project" | "issue", resourceId: string) => { + const pathInfo = policyPathForResource(resourceType); + if (pathInfo.table === "agent") { + const agent = await agents.getById(resourceId); + if (!inCompany(agent, companyId)) return null; + const permissions = agent.permissions && typeof agent.permissions === "object" ? agent.permissions as Record : {}; + return { + resourceType, + resourceId, + companyId, + policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object" + ? sanitizeRecord(permissions.authorizationPolicy as Record) + : null, + updatedAt: agent.updatedAt, + }; + } + if (pathInfo.table === "project") { + const project = await projects.getById(resourceId); + if (!inCompany(project, companyId)) return null; + const policy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" + ? (project.executionWorkspacePolicy as unknown as Record).authorizationPolicy + : null; + return { + resourceType, + resourceId, + companyId, + policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, + updatedAt: project.updatedAt, + }; + } + if (pathInfo.table === "issue") { + const issue = await issues.getById(resourceId); + if (!inCompany(issue, companyId)) return null; + const policy = issue.executionPolicy && typeof issue.executionPolicy === "object" + ? (issue.executionPolicy as Record).authorizationPolicy + : null; + return { + resourceType, + resourceId, + companyId, + policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record) : null, + updatedAt: issue.updatedAt, + }; + } + const company = await companies.getById(resourceId); + if (!company || company.id !== companyId) return null; + return { resourceType, resourceId, companyId, policy: null, updatedAt: company.updatedAt }; + }; + return { config: { async get() { @@ -1116,6 +1358,9 @@ export function buildHostServices( projectId: row.projectId, name, path, + repoUrl: row.repoUrl, + repoRef: row.repoRef, + defaultRef: row.defaultRef, isPrimary: row.isPrimary, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -1135,6 +1380,9 @@ export function buildHostServices( projectId: project.id, name, path, + repoUrl: row?.repoUrl ?? project.codebase.repoUrl, + repoRef: row?.repoRef ?? project.codebase.repoRef, + defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), @@ -1158,6 +1406,9 @@ export function buildHostServices( projectId: project.id, name, path, + repoUrl: row?.repoUrl ?? project.codebase.repoUrl, + repoRef: row?.repoRef ?? project.codebase.repoRef, + defaultRef: row?.defaultRef ?? project.codebase.defaultRef, isPrimary: true, createdAt: (row?.createdAt ?? project.createdAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), @@ -1197,6 +1448,18 @@ export function buildHostServices( }, }, + executionWorkspaces: { + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const workspace = await executionWorkspaces.getById(params.workspaceId); + if (inCompany(workspace, companyId)) { + return toPluginExecutionWorkspaceMetadata(workspace); + } + return null; + }, + }, + routines: { async managedGet(params) { const companyId = ensureCompanyId(params.companyId); @@ -1940,6 +2203,337 @@ export function buildHostServices( }, }, + access: { + async listMembers(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await access.listMembers(companyId); + const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived"); + const grants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.companyId, companyId)); + const grantsByPrincipal = new Map(); + for (const grant of grants) { + const key = `${grant.principalType}:${grant.principalId}`; + const existing = grantsByPrincipal.get(key) ?? []; + existing.push(grant); + grantsByPrincipal.set(key, existing); + } + return visibleRows.map((member) => ({ + ...member, + principalType: member.principalType as PrincipalType, + status: member.status as "pending" | "active" | "suspended" | "archived", + grants: (grantsByPrincipal.get(`${member.principalType}:${member.principalId}`) ?? []).map(redactGrant), + })); + }, + async getMember(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return loadPluginMember(companyId, params.memberId); + }, + async updateMember(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const updated = await access.updateMember(companyId, params.memberId, params.patch); + if (!updated) throw new Error("Member not found"); + await logPluginActivity({ + companyId, + action: "company_member.updated_by_plugin", + entityType: "company_membership", + entityId: params.memberId, + details: { + patch: sanitizeRecord(params.patch as Record), + }, + }); + return (await loadPluginMember(companyId, params.memberId))!; + }, + async listInvites(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const limit = Math.min(Math.max(Number(params.limit ?? 20), 1), 100); + const offset = Math.max(Number(params.offset ?? 0), 0); + const stateClause = inviteStateWhereClause(params.state); + const rows = await db + .select() + .from(invites) + .where(stateClause ? and(eq(invites.companyId, companyId), stateClause) : eq(invites.companyId, companyId)) + .orderBy(desc(invites.createdAt)) + .limit(limit + 1) + .offset(offset); + const hasMore = rows.length > limit; + return { + invites: rows.slice(0, limit).map(redactInvite), + nextOffset: hasMore ? offset + limit : null, + }; + }, + async createInvite(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const normalizedAgentMessage = typeof params.agentMessage === "string" + ? params.agentMessage.trim() || null + : null; + const allowedJoinTypes = params.allowedJoinTypes ?? "both"; + const humanRole = allowedJoinTypes === "agent" ? null : params.humanRole ?? "operator"; + const insertValues = { + companyId, + inviteType: "company_join" as const, + allowedJoinTypes, + defaultsPayload: mergeInviteDefaults(params.defaultsPayload ?? null, normalizedAgentMessage, humanRole), + expiresAt: new Date(Date.now() + COMPANY_INVITE_TTL_MS), + invitedByUserId: null, + }; + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + created = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken), + }) + .returning() + .then((rows) => rows[0] ?? null); + token = candidateToken; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) throw error; + } + } + if (!token || !created) throw new Error("Failed to generate a unique invite token"); + await logPluginActivity({ + companyId, + action: "invite.created_by_plugin", + entityType: "invite", + entityId: created.id, + details: { + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage), + }, + }); + return { ...redactInvite(created), token }; + }, + async revokeInvite(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const invite = await db + .select() + .from(invites) + .where(and(eq(invites.id, params.inviteId), eq(invites.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!invite) throw new Error("Invite not found"); + if (invite.acceptedAt) throw new Error("Invite already consumed"); + if (invite.revokedAt) return redactInvite(invite); + const revoked = await db + .update(invites) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where(eq(invites.id, invite.id)) + .returning() + .then((rows) => rows[0] ?? invite); + await logPluginActivity({ + companyId, + action: "invite.revoked_by_plugin", + entityType: "invite", + entityId: invite.id, + }); + return redactInvite(revoked); + }, + }, + + authorization: { + async listGrants(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const conditions = [ + eq(principalPermissionGrants.companyId, companyId), + params.principalType ? eq(principalPermissionGrants.principalType, params.principalType) : undefined, + params.principalId ? eq(principalPermissionGrants.principalId, params.principalId) : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + const rows = await db + .select() + .from(principalPermissionGrants) + .where(and(...conditions)) + .orderBy(principalPermissionGrants.principalType, principalPermissionGrants.principalId, principalPermissionGrants.permissionKey); + return rows.map(redactGrant); + }, + async setGrants(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + if (params.principalType !== "agent" && params.principalType !== "user") { + throw new Error("principalType must be 'agent' or 'user'"); + } + if (params.principalType === "agent") { + requireInCompany("Agent", await agents.getById(params.principalId), companyId); + } else { + const membership = await access.getMembership(companyId, params.principalType as PrincipalType, params.principalId); + if (!membership) throw new Error("Principal is not a member of this company"); + } + await access.setPrincipalGrants( + companyId, + params.principalType as PrincipalType, + params.principalId, + params.grants.map((grant) => ({ + permissionKey: grant.permissionKey as PermissionKey, + scope: grant.scope ? sanitizeRecord(grant.scope) : null, + })), + params.grantedByUserId ?? null, + ); + await logPluginActivity({ + companyId, + action: "authorization.grants_updated_by_plugin", + entityType: "principal_permission_grants", + entityId: `${params.principalType}:${params.principalId}`, + details: { grantCount: params.grants.length }, + }); + return access + .listPrincipalGrants(companyId, params.principalType as PrincipalType, params.principalId) + .then((rows) => rows.map(redactGrant)); + }, + async policySummary(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const [members, grants] = await Promise.all([ + access.listMembers(companyId), + db + .select({ id: principalPermissionGrants.id }) + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.companyId, companyId)), + ]); + return { + companyId, + permissionsMode: "simple" as const, + memberCount: members.length, + activeMemberCount: members.filter((member) => member.status === "active").length, + grantCount: grants.length, + advancedPolicyAvailable: false as const, + }; + }, + async getPolicy(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); + }, + async updatePolicy(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const policy = params.policy ? sanitizeRecord(params.policy) : null; + if (params.resourceType === "agent") { + const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId); + const permissions = agent.permissions && typeof agent.permissions === "object" + ? { ...(agent.permissions as Record) } + : {}; + if (policy) permissions.authorizationPolicy = policy; + else delete permissions.authorizationPolicy; + await db + .update(agentsTable) + .set({ permissions, updatedAt: new Date() }) + .where(eq(agentsTable.id, agent.id)); + } else if (params.resourceType === "project") { + const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId); + const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object" + ? { ...(project.executionWorkspacePolicy as unknown as Record) } + : {}; + if (policy) executionWorkspacePolicy.authorizationPolicy = policy; + else delete executionWorkspacePolicy.authorizationPolicy; + await db + .update(projectsTable) + .set({ executionWorkspacePolicy, updatedAt: new Date() }) + .where(eq(projectsTable.id, project.id)); + } else if (params.resourceType === "issue") { + const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId); + const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object" + ? { ...(issue.executionPolicy as Record) } + : {}; + if (policy) executionPolicy.authorizationPolicy = policy; + else delete executionPolicy.authorizationPolicy; + await db + .update(issuesTable) + .set({ executionPolicy, updatedAt: new Date() }) + .where(eq(issuesTable.id, issue.id)); + } else { + const company = await companies.getById(params.resourceId); + if (!company || company.id !== companyId) throw new Error("Company not found"); + throw new Error("Company authorization policy updates are not supported by the current core schema"); + } + await logPluginActivity({ + companyId, + action: "authorization.policy_updated_by_plugin", + entityType: params.resourceType, + entityId: params.resourceId, + details: { hasPolicy: Boolean(policy) }, + }); + const updated = await readAuthorizationPolicy(companyId, params.resourceType, params.resourceId); + if (!updated) throw new Error("Policy resource not found"); + return updated; + }, + async previewAssignment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return authorization.decide({ + actor: pluginAssignmentActor(params.actor), + action: "tasks:assign", + resource: { type: "issue", companyId, ...params.target }, + scope: { + issueId: params.target.issueId ?? null, + projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, + assigneeAgentId: params.target.assigneeAgentId ?? null, + assigneeUserId: params.target.assigneeUserId ?? null, + }, + }); + }, + async explainAssignment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return authorization.decide({ + actor: pluginAssignmentActor(params.actor), + action: "tasks:assign", + resource: { type: "issue", companyId, ...params.target }, + scope: { + issueId: params.target.issueId ?? null, + projectId: params.target.projectId ?? null, + parentIssueId: params.target.parentIssueId ?? null, + assigneeAgentId: params.target.assigneeAgentId ?? null, + assigneeUserId: params.target.assigneeUserId ?? null, + }, + }); + }, + async searchAudit(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const limit = Math.min(Math.max(Number(params.limit ?? 50), 1), 100); + const offset = Math.max(Number(params.offset ?? 0), 0); + const decisionFilter = typeof params.decision === "string" && params.decision.trim() + ? params.decision.trim().toLowerCase() + : null; + const conditions = [ + eq(activityLog.companyId, companyId), + params.action ? eq(activityLog.action, params.action) : undefined, + params.actorType ? eq(activityLog.actorType, params.actorType) : undefined, + params.actorId ? eq(activityLog.actorId, params.actorId) : undefined, + params.entityType ? eq(activityLog.entityType, params.entityType) : undefined, + params.entityId ? eq(activityLog.entityId, params.entityId) : undefined, + decisionFilter ? authorizationAuditDecisionCondition(decisionFilter) : undefined, + ].filter((condition): condition is NonNullable => Boolean(condition)); + const rows = await db + .select() + .from(activityLog) + .where(and(...conditions)) + .orderBy(desc(activityLog.createdAt)) + .limit(limit) + .offset(offset); + return rows.map((row) => ({ + ...row, + details: row.details && typeof row.details === "object" + ? sanitizeRecord(row.details) + : row.details ?? null, + })); + }, + }, + agentSessions: { async create(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts index e9f9b02b..24fa13e7 100644 --- a/server/src/services/plugin-lifecycle.ts +++ b/server/src/services/plugin-lifecycle.ts @@ -776,19 +776,47 @@ export function pluginLifecycleManager( ); } - log.info( - { pluginId, pluginKey: plugin.pluginKey }, - "plugin lifecycle: restarting worker", - ); + const supportsRuntimeActivation = + typeof pluginLoaderInstance.hasRuntimeServices === "function" + && typeof pluginLoaderInstance.loadSingle === "function" + && typeof pluginLoaderInstance.unloadSingle === "function" + && pluginLoaderInstance.hasRuntimeServices(); - await handle.restart(); + if (supportsRuntimeActivation) { + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: reloading plugin (re-reading manifest, re-applying pending migrations, restarting worker)", + ); - emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey }); - emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey }); + // Full deactivate+reactivate cycle (not just `handle.restart()`) so that: + // - the manifest is re-read from disk, picking up newly declared + // `migrations/*.sql` files and any other manifest changes, + // - `applyMigrations` runs idempotently against the up-to-date + // migrations directory — pending migrations get applied, already- + // applied ones are skipped via the `pluginMigrations` table, + // - the worker subprocess is replaced with one loading the freshly + // built bundle. + // + // Bouncing the worker process alone (`handle.restart()`) leaves plugin + // schema out of sync with worker code whenever a hot reload adds a new + // migration, which makes downstream queries fail against missing tables. + await deactivatePluginRuntime(pluginId, plugin.pluginKey); + await activateReadyPlugin(pluginId); + } else { + // No runtime activation services wired in (e.g. state-only test harness) + // — fall back to a bare worker subprocess bounce. + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: restarting worker (runtime services unavailable; skipping migration re-apply)", + ); + await handle.restart(); + emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey }); + emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey }); + } log.info( { pluginId, pluginKey: plugin.pluginKey }, - "plugin lifecycle: worker restarted", + "plugin lifecycle: plugin reloaded", ); }, diff --git a/server/src/services/plugin-local-folders.ts b/server/src/services/plugin-local-folders.ts index 049b6e66..2243e970 100644 --- a/server/src/services/plugin-local-folders.ts +++ b/server/src/services/plugin-local-folders.ts @@ -486,8 +486,12 @@ export async function writePluginLocalFolderTextAtomic( contents: string, ) { const rootRealPath = await fs.realpath(rootPath); - const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath); - await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true }); + const normalized = normalizeRelativePath(relativePath); + const parentRelativePath = path.dirname(normalized); + if (parentRelativePath !== ".") { + await ensureDirectoryInsideRoot(rootRealPath, parentRelativePath); + } + const resolved = await resolvePluginLocalFolderPath(rootRealPath, normalized); await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath)); const tempPath = path.join( path.dirname(resolved.absolutePath), diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index facae176..71cca23a 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -19,6 +19,7 @@ */ import { fork, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; @@ -39,9 +40,12 @@ import { } from "@paperclipai/plugin-sdk"; import type { JsonRpcId, + PluginInvocationContext, + PluginInvocationScope, JsonRpcResponse, JsonRpcRequest, JsonRpcNotification, + WorkerHostCallContext, HostToWorkerMethodName, HostToWorkerMethods, WorkerToHostMethodName, @@ -57,8 +61,8 @@ import { logger } from "../middleware/logger.js"; /** Default timeout for RPC calls in milliseconds. */ const DEFAULT_RPC_TIMEOUT_MS = 30_000; -/** Hard upper bound for any RPC timeout (60 minutes). Prevents unbounded waits. */ -const MAX_RPC_TIMEOUT_MS = 60 * 60 * 1_000; +/** Hard upper bound for any RPC timeout (15 minutes). Prevents unbounded waits. */ +const MAX_RPC_TIMEOUT_MS = 15 * 60 * 1_000; /** Timeout for the initialize RPC call. */ const INITIALIZE_TIMEOUT_MS = 15_000; @@ -108,6 +112,7 @@ export type WorkerStatus = */ export type WorkerToHostHandler = ( params: WorkerToHostMethods[M][0], + context?: WorkerHostCallContext, ) => Promise; /** @@ -199,6 +204,13 @@ interface PendingRequest { timer: ReturnType; /** Timestamp when the request was sent. */ sentAt: number; + /** Active host-owned invocation id attached to this host→worker call. */ + invocationId?: string; +} + +interface ActiveInvocation { + scope: PluginInvocationScope; + timer?: ReturnType; } // --------------------------------------------------------------------------- @@ -379,6 +391,7 @@ export function createPluginWorkerHandle( // Pending RPC requests awaiting a response const pendingRequests = new Map(); let nextRequestId = 1; + const activeInvocations = new Map(); // Optional methods reported by the worker during initialization let supportedMethods: string[] = []; @@ -424,6 +437,14 @@ export function createPluginWorkerHandle( childProcess.stdin.write(serialized); } + function errorCodeForWorkerHostError(err: unknown): number { + const code = (err as { code?: unknown } | null)?.code; + const pluginErrorCodes: readonly number[] = Object.values(PLUGIN_RPC_ERROR_CODES); + return typeof code === "number" && pluginErrorCodes.includes(code) + ? code + : JSONRPC_ERROR_CODES.INTERNAL_ERROR; + } + // ----------------------------------------------------------------------- // Incoming message handling // ----------------------------------------------------------------------- @@ -475,13 +496,85 @@ export function createPluginWorkerHandle( pending.resolve(response); } + function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + } + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function deriveInvocationScope( + method: HostToWorkerMethodName | string, + params: unknown, + ): PluginInvocationScope | null { + if (!isRecord(params)) return null; + + const directCompanyId = readNonEmptyString(params.companyId); + if (directCompanyId) return { companyId: directCompanyId }; + + if (method === "performAction" && isRecord(params.actorContext)) { + const companyId = readNonEmptyString(params.actorContext.companyId); + return companyId ? { companyId } : null; + } + + if (method === "executeTool" && isRecord(params.runContext)) { + const companyId = readNonEmptyString(params.runContext.companyId); + return companyId ? { companyId } : null; + } + + if (method === "onEvent" && isRecord(params.event)) { + const companyId = readNonEmptyString(params.event.companyId); + return companyId ? { companyId } : null; + } + + return null; + } + + function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext { + const invocation: PluginInvocationContext = { + id: randomUUID(), + scope, + }; + const entry: ActiveInvocation = { scope }; + if (ttlMs !== undefined) { + entry.timer = setTimeout(() => { + activeInvocations.delete(invocation.id); + }, ttlMs); + if (entry.timer.unref) entry.timer.unref(); + } + activeInvocations.set(invocation.id, entry); + return invocation; + } + + function clearInvocation(invocation: PluginInvocationContext | null): void { + if (!invocation) return; + const entry = activeInvocations.get(invocation.id); + if (entry?.timer) clearTimeout(entry.timer); + activeInvocations.delete(invocation.id); + } + + function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext { + const invocationId = readNonEmptyString( + (message as { paperclipInvocationId?: unknown }).paperclipInvocationId, + ); + if (!invocationId) { + const hasActiveInvocation = activeInvocations.size > 0 || + Array.from(pendingRequests.values()).some((pending) => pending.invocationId); + return hasActiveInvocation ? { invalidInvocationScope: true } : {}; + } + const entry = activeInvocations.get(invocationId); + if (!entry) return { invalidInvocationScope: true }; + return { invocationScope: entry.scope }; + } + /** * Handle a JSON-RPC request from the worker (worker→host call). */ async function handleWorkerRequest(request: JsonRpcRequest): Promise { const method = request.method as WorkerToHostMethodName; const handler = options.hostHandlers[method] as - | ((params: unknown) => Promise) + | ((params: unknown, context?: WorkerHostCallContext) => Promise) | undefined; if (!handler) { @@ -501,7 +594,7 @@ export function createPluginWorkerHandle( } try { - const result = await handler(request.params); + const result = await handler(request.params, contextForWorkerMessage(request)); sendMessage({ jsonrpc: JSONRPC_VERSION, id: request.id, @@ -514,7 +607,7 @@ export function createPluginWorkerHandle( sendMessage( createErrorResponse( request.id, - JSONRPC_ERROR_CODES.INTERNAL_ERROR, + errorCodeForWorkerHostError(err), errorMessage, ), ); @@ -572,12 +665,28 @@ export function createPluginWorkerHandle( notification.method === "streams.close" ) { const params = (notification.params ?? {}) as Record; + const companyId = String(params.companyId ?? ""); + const context = contextForWorkerMessage(notification); + if (context.invalidInvocationScope) { + log.warn( + { method: notification.method, companyId }, + "dropping plugin stream notification with invalid invocation scope", + ); + return; + } + const allowedCompanyId = context.invocationScope?.companyId; + if (allowedCompanyId && companyId !== allowedCompanyId) { + log.warn( + { method: notification.method, companyId, allowedCompanyId }, + "dropping plugin stream notification outside invocation company scope", + ); + return; + } // Track open channels so we can emit synthetic close on crash if (notification.method === "streams.open") { const ch = String(params.channel ?? ""); - const co = String(params.companyId ?? ""); - if (ch) openStreamChannels.set(ch, co); + if (ch) openStreamChannels.set(ch, companyId); } else if (notification.method === "streams.close") { openStreamChannels.delete(String(params.channel ?? "")); } @@ -653,7 +762,9 @@ export function createPluginWorkerHandle( // Handle process errors (e.g. spawn failure) child.on("error", (err) => { log.error({ err: err.message }, "worker process error"); - emitter.emit("error", { pluginId, error: err }); + if (emitter.listenerCount("error") > 0) { + emitter.emit("error", { pluginId, error: err }); + } if (status === "starting") { setStatus("crashed"); rejectAllPending( @@ -758,6 +869,10 @@ export function createPluginWorkerHandle( ); } pendingRequests.clear(); + for (const invocation of activeInvocations.values()) { + if (invocation.timer) clearTimeout(invocation.timer); + } + activeInvocations.clear(); } // ----------------------------------------------------------------------- @@ -1018,6 +1133,8 @@ export function createPluginWorkerHandle( const id = nextRequestId++; const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); + const invocationScope = deriveInvocationScope(method, params); + const invocation = invocationScope ? registerInvocation(invocationScope) : null; // Guard against double-settlement. When a process exits all pending // requests are rejected via rejectAllPending(), but the timeout timer @@ -1030,6 +1147,7 @@ export function createPluginWorkerHandle( settled = true; clearTimeout(timer); pendingRequests.delete(id); + clearInvocation(invocation); fn(value); }; @@ -1057,16 +1175,21 @@ export function createPluginWorkerHandle( }, timer, sentAt: Date.now(), + invocationId: invocation?.id, }; pendingRequests.set(id, pending); try { - const request = createRequest(method, params, id); + const request = { + ...createRequest(method, params, id), + ...(invocation ? { paperclipInvocation: invocation } : {}), + }; sendMessage(request); } catch (err) { clearTimeout(timer); pendingRequests.delete(id); + clearInvocation(invocation); reject( new Error( `Failed to send "${method}" to worker: ${ @@ -1133,13 +1256,17 @@ export function createPluginWorkerHandle( notify(method: string, params: unknown) { if (status !== "running") return; + const invocationScope = deriveInvocationScope(method, params); + const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null; try { sendMessage({ jsonrpc: JSONRPC_VERSION, method, params, + ...(invocation ? { paperclipInvocation: invocation } : {}), }); } catch { + clearInvocation(invocation); log.warn({ method }, "failed to send notification to worker"); } }, diff --git a/server/src/services/principal-access-compatibility.ts b/server/src/services/principal-access-compatibility.ts new file mode 100644 index 00000000..cb1e86be --- /dev/null +++ b/server/src/services/principal-access-compatibility.ts @@ -0,0 +1,141 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; + +type GrantInput = { + permissionKey: PermissionKey; + scope?: Record | null; +}; + +export type PrincipalAccessCompatibilityBackfillStats = { + agentMembershipsInserted: number; + humanGrantsInserted: number; +}; + +export async function insertMissingPrincipalGrants( + db: Db, + input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + grants: GrantInput[]; + grantedByUserId: string | null; + }, +): Promise { + if (input.grants.length === 0) return 0; + + const now = new Date(); + const inserted = await db + .insert(principalPermissionGrants) + .values( + input.grants.map((grant) => ({ + companyId: input.companyId, + principalType: input.principalType, + principalId: input.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: input.grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + principalPermissionGrants.companyId, + principalPermissionGrants.principalType, + principalPermissionGrants.principalId, + principalPermissionGrants.permissionKey, + ], + }) + .returning({ id: principalPermissionGrants.id }); + + return inserted.length; +} + +export async function ensureHumanRoleDefaultGrants( + db: Db, + input: { + companyId: string; + principalId: string; + membershipRole: string | null | undefined; + grantedByUserId: string | null; + }, +): Promise { + const role = normalizeHumanRole(input.membershipRole, "operator"); + return insertMissingPrincipalGrants(db, { + companyId: input.companyId, + principalType: "user", + principalId: input.principalId, + grants: grantsForHumanRole(role), + grantedByUserId: input.grantedByUserId, + }); +} + +export async function backfillPrincipalAccessCompatibility( + db: Db, +): Promise { + const now = new Date(); + const nonTerminalAgents = await db + .select({ + companyId: agents.companyId, + principalId: agents.id, + }) + .from(agents) + .where(notInArray(agents.status, ["pending_approval", "terminated"])); + + const agentMembershipsInserted = nonTerminalAgents.length > 0 + ? await db + .insert(companyMemberships) + .values( + nonTerminalAgents.map((agent) => ({ + companyId: agent.companyId, + principalType: "agent", + principalId: agent.principalId, + status: "active", + membershipRole: "member", + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + companyMemberships.companyId, + companyMemberships.principalType, + companyMemberships.principalId, + ], + }) + .returning({ id: companyMemberships.id }) + .then((rows) => rows.length) + : 0; + + const activeHumanMemberships = await db + .select({ + companyId: companyMemberships.companyId, + principalId: companyMemberships.principalId, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ); + + let humanGrantsInserted = 0; + for (const membership of activeHumanMemberships) { + humanGrantsInserted += await ensureHumanRoleDefaultGrants(db, { + companyId: membership.companyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); + } + + return { + agentMembershipsInserted, + humanGrantsInserted, + }; +} diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts index c9cd0dcd..3c3c615d 100644 --- a/server/src/services/productivity-review.ts +++ b/server/src/services/productivity-review.ts @@ -691,7 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque goalId: evidence.sourceIssue.goalId, billingCode: evidence.sourceIssue.billingCode, assigneeAgentId: ownerAgentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(), + assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND, originId: evidence.sourceIssue.id, originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id), @@ -741,7 +741,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque issueId: review.id, sourceIssueId: evidence.sourceIssue.id, trigger: evidence.trigger, - }), + }, "status_only"), requestedByActorType: "system", requestedByActorId: "productivity_review", contextSnapshot: withRecoveryModelProfileHint({ @@ -751,7 +751,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque source: PRODUCTIVITY_REVIEW_ORIGIN_KIND, sourceIssueId: evidence.sourceIssue.id, productivityReviewTrigger: evidence.trigger, - }), + }, "status_only"), }); } diff --git a/server/src/services/recovery/model-profile-hint.test.ts b/server/src/services/recovery/model-profile-hint.test.ts new file mode 100644 index 00000000..77639ee4 --- /dev/null +++ b/server/src/services/recovery/model-profile-hint.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + recoveryAssigneeAdapterOverrides, + scrubRecoveryModelProfileHints, + withRecoveryModelProfileHint, +} from "./model-profile-hint.js"; + +describe("recovery model profile policy", () => { + it("allows cheap only for status-only recovery and adds guard context", () => { + expect(withRecoveryModelProfileHint({ issueId: "issue-1" }, "status_only")).toEqual({ + issueId: "issue-1", + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + modelProfile: "cheap", + }); + expect(recoveryAssigneeAdapterOverrides("status_only")).toEqual({ modelProfile: "cheap" }); + }); + + it("scrubs inherited cheap hints from normal model source-work retries", () => { + expect(withRecoveryModelProfileHint({ + issueId: "issue-1", + retryOfRunId: "run-1", + modelProfile: "cheap", + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, + }, "normal_model")).toEqual({ + issueId: "issue-1", + retryOfRunId: "run-1", + }); + }); + + it("can scrub copied downstream source-work contexts without applying a profile", () => { + expect(scrubRecoveryModelProfileHints({ + taskId: "source-task", + modelProfile: "cheap", + paperclipModelProfile: { requested: "cheap" }, + allowDocumentUpdates: false, + })).toEqual({ taskId: "source-task" }); + }); +}); diff --git a/server/src/services/recovery/model-profile-hint.ts b/server/src/services/recovery/model-profile-hint.ts index 51e75e44..bdbdccb9 100644 --- a/server/src/services/recovery/model-profile-hint.ts +++ b/server/src/services/recovery/model-profile-hint.ts @@ -1,14 +1,65 @@ export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const; +export type RecoveryModelProfileWorkClass = "status_only" | "normal_model"; + +export const STATUS_ONLY_RECOVERY_GUARD_CONTEXT = { + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, +} as const; + +const RECOVERY_MODEL_PROFILE_HINT_KEYS = [ + "modelProfile", + "paperclipModelProfile", + "recoveryIntent", + "allowDeliverableWork", + "allowDocumentUpdates", + "resumeRequiresNormalModel", +] as const; + +type RecoveryModelProfileHintKey = (typeof RECOVERY_MODEL_PROFILE_HINT_KEYS)[number]; +type WithoutRecoveryModelProfileHints = Omit; + +export function scrubRecoveryModelProfileHints>( + input: T, +): WithoutRecoveryModelProfileHints { + const output: Record = { ...input }; + for (const key of RECOVERY_MODEL_PROFILE_HINT_KEYS) { + delete output[key]; + } + return output as WithoutRecoveryModelProfileHints; +} + export function withRecoveryModelProfileHint>( input: T, -): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } { + workClass: "normal_model", +): WithoutRecoveryModelProfileHints; +export function withRecoveryModelProfileHint>( + input: T, + workClass: "status_only", +): WithoutRecoveryModelProfileHints & typeof STATUS_ONLY_RECOVERY_GUARD_CONTEXT & { + modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY; +}; +export function withRecoveryModelProfileHint>( + input: T, + workClass: RecoveryModelProfileWorkClass, +): + | WithoutRecoveryModelProfileHints + | (WithoutRecoveryModelProfileHints & typeof STATUS_ONLY_RECOVERY_GUARD_CONTEXT & { + modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY; + }) { + if (workClass === "normal_model") { + return scrubRecoveryModelProfileHints(input); + } + return { - ...input, + ...scrubRecoveryModelProfileHints(input), + ...STATUS_ONLY_RECOVERY_GUARD_CONTEXT, modelProfile: RECOVERY_MODEL_PROFILE_KEY, }; } -export function recoveryAssigneeAdapterOverrides() { +export function recoveryAssigneeAdapterOverrides(_workClass: Extract) { return { modelProfile: RECOVERY_MODEL_PROFILE_KEY }; } diff --git a/server/src/services/recovery/run-liveness-continuations.ts b/server/src/services/recovery/run-liveness-continuations.ts index ecc93e0b..dd199b31 100644 --- a/server/src/services/recovery/run-liveness-continuations.ts +++ b/server/src/services/recovery/run-liveness-continuations.ts @@ -166,7 +166,7 @@ export function decideRunLivenessContinuation(input: { instruction: nextAction ?? "The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.", - }); + }, "normal_model"); return { kind: "enqueue", @@ -184,6 +184,6 @@ export function decideRunLivenessContinuation(input: { livenessContinuationState: livenessState, livenessContinuationReason: livenessReason, livenessContinuationInstruction: payload.instruction, - }), + }, "normal_model"), }; } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f94d5b52..0f12e604 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -11,11 +11,12 @@ import { agents, agentWakeupRequests, approvals, + activityLog, companies, - issueComments, heartbeatRunEvents, heartbeatRunWatchdogDecisions, heartbeatRuns, + issueComments, issueApprovals, issueRecoveryActions, issueRelations, @@ -26,6 +27,7 @@ import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js"; import { runningProcesses } from "../../adapters/index.js"; import { forbidden, notFound } from "../../errors.js"; import { logger } from "../../middleware/logger.js"; +import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local-service-supervisor.js"; import { redactCurrentUserText } from "../../log-redaction.js"; import { redactSensitiveText } from "../../redaction.js"; import { logActivity } from "../activity-log.js"; @@ -68,6 +70,15 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024; const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery; const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const SESSIONED_LOCAL_ADAPTERS = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "hermes_local", + "opencode_local", + "pi_local", +]); type RecoveryWakeupOptions = { source?: "timer" | "assignment" | "on_demand" | "automation"; @@ -488,7 +499,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) payload: withRecoveryModelProfileHint({ issueId: input.issueId, ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), - }), + }, "normal_model"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -498,7 +509,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) retryReason: input.retryReason, source: input.source, ...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}), - }), + }, "normal_model"), }); if (queued && input.retryOfRunId) { @@ -524,7 +535,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) payload: withRecoveryModelProfileHint({ issueId: issue.id, mutation: "assigned_todo_liveness_dispatch", - }), + }, "normal_model"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -532,7 +543,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) taskId: issue.id, wakeReason: "issue_assigned", source: "issue.assigned_todo_liveness_dispatch", - }), + }, "normal_model"), }); } @@ -639,7 +650,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) payload: withRecoveryModelProfileHint({ issueId: candidate.id, mutation: "unassigned_blocker_recovery", - }), + }, "normal_model"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -647,7 +658,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) taskId: candidate.id, wakeReason: "issue_assigned", source: "issue.unassigned_blocker_recovery", - }), + }, "normal_model"), }); if (queued) { @@ -673,6 +684,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return `stale_active_run:${companyId}:${runId}`; } + function isTerminalIssueStatus(status: string | null | undefined) { + return status === "done" || status === "cancelled"; + } + + function isRecoveryOriginIssue(issue: typeof issues.$inferSelect) { + return Object.values(RECOVERY_ORIGIN_KINDS).includes( + issue.originKind as typeof RECOVERY_ORIGIN_KINDS[keyof typeof RECOVERY_ORIGIN_KINDS], + ); + } + function silenceStartedAtForRun(run: Pick) { return run.lastOutputAt ?? run.processStartedAt ?? run.startedAt ?? run.createdAt ?? null; } @@ -798,6 +819,309 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return issue ?? null; } + async function latestSameRunSourceTerminalEvidence(input: { + run: typeof heartbeatRuns.$inferSelect; + sourceIssue: typeof issues.$inferSelect; + evidenceAfter: Date | null; + }) { + if (!isTerminalIssueStatus(input.sourceIssue.status)) return null; + const after = input.evidenceAfter ?? input.run.startedAt ?? input.run.createdAt ?? null; + const activityPredicates = [ + eq(activityLog.companyId, input.run.companyId), + eq(activityLog.runId, input.run.id), + eq(activityLog.action, "issue.updated"), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, input.sourceIssue.id), + sql`${activityLog.details} ->> 'status' = ${input.sourceIssue.status}`, + ]; + if (after) { + activityPredicates.push(gte(activityLog.createdAt, after)); + } + + const activity = await db + .select({ + id: activityLog.id, + createdAt: activityLog.createdAt, + action: activityLog.action, + }) + .from(activityLog) + .where(and(...activityPredicates)) + .orderBy(desc(activityLog.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (activity) { + return { + kind: "activity" as const, + id: activity.id, + createdAt: activity.createdAt, + action: activity.action, + }; + } + return null; + } + + async function nextRunEventSeq(runId: string) { + const [row] = await db + .select({ maxSeq: sql`max(${heartbeatRunEvents.seq})` }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + return Number(row?.maxSeq ?? 0) + 1; + } + + async function appendRecoveryRunEvent( + run: typeof heartbeatRuns.$inferSelect, + event: { + level: "info" | "warn" | "error"; + message: string; + payload?: Record; + }, + ) { + await db.insert(heartbeatRunEvents).values({ + companyId: run.companyId, + runId: run.id, + agentId: run.agentId, + seq: await nextRunEventSeq(run.id), + eventType: "lifecycle", + stream: "system", + level: event.level, + message: event.message, + payload: event.payload ?? null, + }); + } + + async function cleanupSourceResolvedRunProcess(input: { + run: typeof heartbeatRuns.$inferSelect; + runningAgent: typeof agents.$inferSelect; + }) { + if (!SESSIONED_LOCAL_ADAPTERS.has(input.runningAgent.adapterType)) { + return { + attempted: false, + outcome: "skipped_non_local_adapter", + adapterType: input.runningAgent.adapterType, + }; + } + + const running = runningProcesses.get(input.run.id); + const pid = running?.child.pid ?? input.run.processPid ?? null; + const processGroupId = running?.processGroupId ?? input.run.processGroupId ?? null; + if (typeof pid !== "number" && typeof processGroupId !== "number") { + return { + attempted: false, + outcome: "no_process_metadata", + adapterType: input.runningAgent.adapterType, + }; + } + + const wasAlive = + (typeof pid === "number" && isPidAlive(pid)) || + (typeof processGroupId === "number" && isProcessGroupAlive(processGroupId)); + if (!wasAlive) { + runningProcesses.delete(input.run.id); + return { + attempted: false, + outcome: "not_running", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + }; + } + + try { + 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, + }, + running ? { forceAfterMs: Math.max(1, running.graceSec) * 1000 } : undefined, + ); + runningProcesses.delete(input.run.id); + const stillAlive = + (typeof pid === "number" && isPidAlive(pid)) || + (typeof processGroupId === "number" && isProcessGroupAlive(processGroupId)); + return { + attempted: true, + outcome: stillAlive ? "termination_sent_still_running" : "terminated", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + }; + } catch (error) { + return { + attempted: true, + outcome: "failed", + adapterType: input.runningAgent.adapterType, + pid, + processGroupId, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async function finalizeAgentAfterSourceResolvedRun(run: typeof heartbeatRuns.$inferSelect, status: "succeeded" | "cancelled") { + const [runningCountRow] = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.agentId, run.agentId), eq(heartbeatRuns.status, "running"))); + const runningCount = Number(runningCountRow?.count ?? 0); + const nextStatus = runningCount > 0 ? "running" : status === "succeeded" || status === "cancelled" ? "idle" : "error"; + await db + .update(agents) + .set({ + status: nextStatus, + lastHeartbeatAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(agents.id, run.agentId), notInArray(agents.status, ["paused", "terminated"]))); + } + + async function foldSourceResolvedStaleRun(input: { + run: typeof heartbeatRuns.$inferSelect; + runningAgent: typeof agents.$inferSelect; + sourceIssue: typeof issues.$inferSelect; + evidence: Awaited>; + existingEvaluation: Awaited>; + silenceStartedAt: Date | null; + silenceAgeMs: number | null; + now: Date; + }) { + if (!input.evidence) return { kind: "skipped" as const }; + const cleanup = await cleanupSourceResolvedRunProcess({ run: input.run, runningAgent: input.runningAgent }); + const finalRunStatus = input.sourceIssue.status === "cancelled" ? "cancelled" : "succeeded"; + const resultJson = { + ...parseObject(input.run.resultJson), + sourceResolvedWatchdogFold: { + sourceIssueId: input.sourceIssue.id, + sourceIssueIdentifier: input.sourceIssue.identifier, + sourceIssueStatus: input.sourceIssue.status, + sameRunEvidenceKind: input.evidence.kind, + sameRunEvidenceId: input.evidence.id, + sameRunEvidenceAt: input.evidence.createdAt.toISOString(), + silenceStartedAt: input.silenceStartedAt?.toISOString() ?? null, + silenceAgeMs: input.silenceAgeMs, + evaluationIssueId: input.existingEvaluation?.id ?? null, + evaluationIssueIdentifier: input.existingEvaluation?.identifier ?? null, + cleanup, + }, + }; + const finalizedRun = await db.transaction(async (tx) => { + const [updatedRun] = await tx + .update(heartbeatRuns) + .set({ + status: finalRunStatus, + finishedAt: input.now, + error: null, + errorCode: null, + resultJson, + updatedAt: input.now, + }) + .where(and(eq(heartbeatRuns.id, input.run.id), eq(heartbeatRuns.companyId, input.run.companyId), eq(heartbeatRuns.status, "running"))) + .returning(); + if (!updatedRun) return null; + + if (input.run.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ + status: finalRunStatus === "succeeded" ? "completed" : "cancelled", + finishedAt: input.now, + error: null, + updatedAt: input.now, + }) + .where(and(eq(agentWakeupRequests.id, input.run.wakeupRequestId), eq(agentWakeupRequests.companyId, input.run.companyId))); + } + + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: input.now, + }) + .where( + and( + eq(issues.id, input.sourceIssue.id), + eq(issues.companyId, input.run.companyId), + eq(issues.executionRunId, input.run.id), + ), + ); + + return updatedRun; + }); + if (!finalizedRun) return { kind: "skipped" as const }; + + if (input.existingEvaluation && !isTerminalIssueStatus(input.existingEvaluation.status)) { + await issuesSvc.update(input.existingEvaluation.id, { status: "done" }); + await issuesSvc.addComment(input.existingEvaluation.id, [ + "Source-resolved watchdog fold.", + "", + `- Source issue: ${input.sourceIssue.identifier ?? input.sourceIssue.id}`, + `- Run: \`${input.run.id}\``, + `- Same-run evidence: \`${input.evidence.kind}:${input.evidence.id}\` at ${input.evidence.createdAt.toISOString()}`, + "- Outcome: false positive; the source issue already reached a terminal disposition from this run.", + ].join("\n"), { runId: input.run.id }); + } + + const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(input.run.companyId, input.sourceIssue.id); + if (activeRecoveryAction?.kind === "active_run_watchdog") { + await recoveryActionsSvc.resolveActiveForIssue({ + companyId: input.run.companyId, + sourceIssueId: input.sourceIssue.id, + actionId: activeRecoveryAction.id, + status: "resolved", + outcome: "false_positive", + resolutionNote: "Source issue reached a terminal disposition through durable same-run activity; watchdog folded as source-resolved.", + }); + } + + const [decision] = await db + .insert(heartbeatRunWatchdogDecisions) + .values({ + companyId: input.run.companyId, + runId: input.run.id, + evaluationIssueId: input.existingEvaluation?.id ?? null, + decision: "dismissed_false_positive", + reason: "Source issue already reached a terminal disposition through durable same-run activity.", + createdByRunId: input.run.id, + }) + .returning(); + + await appendRecoveryRunEvent(finalizedRun, { + level: cleanup.outcome === "failed" ? "warn" : "info", + message: "Source-resolved watchdog fold finalized stale active run", + payload: resultJson.sourceResolvedWatchdogFold, + }); + await logActivity(db, { + companyId: input.run.companyId, + actorType: "system", + actorId: "system", + agentId: input.run.agentId, + runId: input.run.id, + action: "heartbeat.output_stale_source_resolved", + entityType: "heartbeat_run", + entityId: input.run.id, + details: { + source: "recovery.scan_silent_active_runs", + sourceIssueId: input.sourceIssue.id, + sourceIssueIdentifier: input.sourceIssue.identifier, + sourceIssueStatus: input.sourceIssue.status, + evaluationIssueId: input.existingEvaluation?.id ?? null, + watchdogDecisionId: decision.id, + sameRunEvidenceKind: input.evidence.kind, + sameRunEvidenceId: input.evidence.id, + sameRunEvidenceAt: input.evidence.createdAt.toISOString(), + cleanup, + }, + }); + await finalizeAgentAfterSourceResolvedRun(finalizedRun, finalRunStatus); + return { kind: "folded" as const, evaluationIssueId: input.existingEvaluation?.id ?? null }; + } + async function resolveStaleRunOwnerAgentId(input: { run: typeof heartbeatRuns.$inferSelect; runningAgent: typeof agents.$inferSelect; @@ -1030,6 +1354,47 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const runningAgent = await getAgent(input.run.agentId); if (!runningAgent || runningAgent.companyId !== input.run.companyId) return { kind: "skipped" as const }; const sourceIssue = await resolveStaleRunSourceIssue(input.run); + const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id); + if (sourceIssue && isRecoveryOriginIssue(sourceIssue)) { + await logActivity(db, { + companyId: input.run.companyId, + actorType: "system", + actorId: "system", + agentId: input.run.agentId, + runId: input.run.id, + action: "heartbeat.output_stale_recovery_recursion_refused", + entityType: "heartbeat_run", + entityId: input.run.id, + details: { + source: "recovery.scan_silent_active_runs", + sourceIssueId: sourceIssue.id, + sourceIssueIdentifier: sourceIssue.identifier, + sourceIssueOriginKind: sourceIssue.originKind, + existingEvaluationIssueId: existing?.id ?? null, + }, + }); + return { kind: "skipped" as const }; + } + const silenceStartedAt = silenceStartedAtForRun(input.run); + if (sourceIssue && isTerminalIssueStatus(sourceIssue.status)) { + const terminalEvidence = await latestSameRunSourceTerminalEvidence({ + run: input.run, + sourceIssue, + evidenceAfter: silenceStartedAt, + }); + if (terminalEvidence) { + return foldSourceResolvedStaleRun({ + run: input.run, + runningAgent, + sourceIssue, + evidence: terminalEvidence, + existingEvaluation: existing, + silenceStartedAt, + silenceAgeMs: silenceAgeMsForRun(input.run, input.now), + now: input.now, + }); + } + } const prefix = await getCompanyIssuePrefix(input.run.companyId); const evidence = await collectStaleRunEvidence({ run: input.run, @@ -1039,7 +1404,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) now: input.now, }); const level = (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious"; - const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id); if (existing) { if (level === "critical" && existing.priority !== "high") { await issuesSvc.update(existing.id, { @@ -1091,7 +1455,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) goalId: sourceIssue?.goalId ?? null, billingCode: sourceIssue?.billingCode ?? null, assigneeAgentId: ownerAgentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(), + assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND, originId: input.run.id, originRunId: input.run.id, @@ -1137,7 +1501,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issueId: evaluation.id, staleRunId: input.run.id, sourceIssueId: sourceIssue?.id ?? null, - }), + }, "status_only"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -1147,7 +1511,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND, staleRunId: input.run.id, sourceIssueId: sourceIssue?.id ?? null, - }), + }, "status_only"), }); } return { kind: "created" as const, evaluationIssueId: evaluation.id }; @@ -1174,6 +1538,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) created: 0, existing: 0, escalated: 0, + folded: 0, snoozed: 0, skipped: 0, evaluationIssueIds: [] as string[], @@ -1188,6 +1553,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (outcome.kind === "created") result.created += 1; else if (outcome.kind === "existing") result.existing += 1; else if (outcome.kind === "escalated") result.escalated += 1; + else if (outcome.kind === "folded") result.folded += 1; else result.skipped += 1; if ("evaluationIssueId" in outcome && outcome.evaluationIssueId) { result.evaluationIssueIds.push(outcome.evaluationIssueId); @@ -1524,7 +1890,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) projectId: input.issue.projectId, goalId: input.issue.goalId, assigneeAgentId: ownerAgentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(), + assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, originId: input.issue.id, originRunId: input.latestRun?.id ?? null, @@ -1554,7 +1920,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sourceIssueId: input.issue.id, strandedRunId: input.latestRun?.id ?? null, recoveryCause, - }), + }, "status_only"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -1565,7 +1931,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sourceIssueId: input.issue.id, strandedRunId: input.latestRun?.id ?? null, recoveryCause, - }), + }, "status_only"), }); return recovery; @@ -1684,7 +2050,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) recoveryActionId: input.action.id, strandedRunId: input.latestRun?.id ?? null, recoveryCause: input.recoveryCause, - }), + }, "status_only"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -1697,7 +2063,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sourceIssueId: input.issue.id, strandedRunId: input.latestRun?.id ?? null, recoveryCause: input.recoveryCause, - }), + }, "status_only"), }); } @@ -2382,7 +2748,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) { const parsed = parseIssueGraphLivenessIncidentKey(row.originId); if (!parsed || parsed.companyId !== row.companyId) return []; - if (parsed.state !== "blocked_by_assigned_backlog_issue") return []; return [ { companyId: row.companyId, @@ -2575,6 +2940,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ) { continue; } + const sourceIssue = await db + .select({ + id: issues.id, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, parsed.companyId), eq(issues.id, parsed.issueId))) + .then((rows) => rows[0] ?? null); + if (sourceIssue && !["done", "cancelled"].includes(sourceIssue.status)) { + const blockerIds = await existingBlockerIssueIds(parsed.companyId, sourceIssue.id); + if (blockerIds.includes(recovery.id)) { + result.activeSkipped += 1; + continue; + } + } if (await removeRecoveryBlockerFromSource(recovery)) { result.blockerRelationsRemoved += 1; } @@ -2590,6 +2970,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return result; } + async function retireDoneLivenessRecoveryBlockers() { + const closedRecoveries = await db + .select() + .from(issues) + .where( + and( + eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), + isNull(issues.hiddenAt), + inArray(issues.status, ["done", "cancelled"]), + ), + ); + + let blockerRelationsRemoved = 0; + for (const recovery of closedRecoveries) { + if (await removeRecoveryBlockerFromSource(recovery)) { + blockerRelationsRemoved += 1; + } + } + + return { blockerRelationsRemoved }; + } + function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) { const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)); return Math.min( @@ -2854,7 +3256,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) projectId: recoveryIssue.projectId, goalId: recoveryIssue.goalId, assigneeAgentId: ownerSelection.agentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(), + assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, originId: input.finding.incidentKey, originFingerprint: livenessRecoveryLeafFingerprint(input.finding), @@ -2940,7 +3342,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sourceIssueId: issue.id, recoveryIssueId: recoveryIssue.id, incidentKey: input.finding.incidentKey, - }), + }, "status_only"), requestedByActorType: "system", requestedByActorId: null, contextSnapshot: withRecoveryModelProfileHint({ @@ -2951,7 +3353,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) sourceIssueId: issue.id, recoveryIssueId: recoveryIssue.id, incidentKey: input.finding.incidentKey, - }), + }, "status_only"), }); logger.warn({ @@ -2985,6 +3387,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const now = new Date(); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); + const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers(); const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const result = { findings: findings.length, @@ -2999,6 +3402,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired, obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped, obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved, + doneRecoveryBlockerRelationsRemoved: doneRecoveryBlockerCleanup.blockerRelationsRemoved, issueIds: [] as string[], escalationIssueIds: [] as string[], retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds, diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index d11aaff5..77e950f7 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -76,11 +76,17 @@ describe("successful run handoff decision", () => { resumeIntent: true, resumeFromRunId: "run-1", modelProfile: "cheap", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, }); expect(decision.contextSnapshot).toMatchObject({ wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON, handoffRequired: true, modelProfile: "cheap", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, }); expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts"); expect(decision.instruction).toContain("Choose **exactly one** outcome"); diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 53ec9b64..33325018 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -323,9 +323,9 @@ export function buildSuccessfulRunHandoffInstruction(input: { "3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.", "", "**Is there more work to do?**", - `4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`, + `4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action. Do not perform the remaining source work in this recovery run; the follow-up/resume wake must use the normal model lane.`, "", - "Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.", + "Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition. If this wake is status-only recovery, document or plan updates are not allowed.", ].join("\n"); } @@ -404,7 +404,7 @@ export function decideSuccessfulRunHandoff(input: { resumeFromRunId: run.id, ...(input.taskKey ? { taskKey: input.taskKey } : {}), instruction, - }); + }, "status_only"); return { kind: "enqueue", @@ -418,6 +418,6 @@ export function decideSuccessfulRunHandoff(input: { ...payload, wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON, livenessState: input.livenessState, - }), + }, "status_only"), }; } diff --git a/server/src/services/resource-memberships.ts b/server/src/services/resource-memberships.ts new file mode 100644 index 00000000..3c7dffc4 --- /dev/null +++ b/server/src/services/resource-memberships.ts @@ -0,0 +1,318 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agentMemberships, + agents, + projectMemberships, + projects, +} from "@paperclipai/db"; +import type { + ResourceMembershipResourceType, + ResourceMembershipState, + ResourceMemberships, + ResourceMembershipUpdateResult, +} from "@paperclipai/shared"; +import { forbidden, notFound } from "../errors.js"; +import { logger } from "../middleware/logger.js"; + +type BoardActor = { + type: "board" | "agent" | "none"; + userId?: string; + companyIds?: string[]; + memberships?: Array<{ + companyId: string; + membershipRole?: string | null; + status?: string; + }>; + isInstanceAdmin?: boolean; + source?: string; +}; + +type PolicyDecision = { + allowed: boolean; + reason?: string | null; + source?: string | null; +}; + +export type ResourceMembershipPolicyHook = (input: { + actor: BoardActor; + companyId: string; + userId: string; + resourceType: ResourceMembershipResourceType; + resourceId: string; + state: ResourceMembershipState; +}) => Promise | PolicyDecision; + +type ResourceMembershipServiceOptions = { + policyHook?: ResourceMembershipPolicyHook | null; +}; + +function defaultJoinedMap( + rows: T[], + key: "projectId" | "agentId", +): Record { + const result: Record = {}; + for (const row of rows) { + const id = row[key]; + if (typeof id !== "string") continue; + result[id] = row.state === "left" ? "left" : "joined"; + } + return result; +} + +function latestDate(...dates: Array): Date | null { + let latest: Date | null = null; + for (const date of dates) { + if (!date) continue; + if (!latest || date.getTime() > latest.getTime()) latest = date; + } + return latest; +} + +function assertBoardSelfMembershipAccess(actor: BoardActor, companyId: string, userId: string) { + if (actor.type !== "board" || !actor.userId) { + throw forbidden("Board user access required"); + } + if (actor.userId !== userId) { + throw forbidden("Users may only update their own resource memberships"); + } + if (actor.source === "local_implicit" || actor.isInstanceAdmin) { + return; + } + const membership = actor.memberships?.find((item) => item.companyId === companyId); + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } +} + +async function evaluatePolicy( + hook: ResourceMembershipPolicyHook | null | undefined, + input: Parameters[0], +): Promise { + if (!hook) return { allowed: true, source: "oss_default" }; + try { + const decision = await hook(input); + return { + allowed: decision.allowed === true, + reason: decision.reason ?? null, + source: decision.source ?? "policy_hook", + }; + } catch (err) { + logger.warn( + { err, companyId: input.companyId, resourceType: input.resourceType, resourceId: input.resourceId }, + "resource membership policy hook failed closed", + ); + return { allowed: false, reason: "policy_hook_failed", source: "policy_hook" }; + } +} + +export function resourceMembershipService(db: Db, options: ResourceMembershipServiceOptions = {}) { + const policyHook = options.policyHook ?? null; + + async function assertMutationAllowed(input: { + actor: BoardActor; + companyId: string; + userId: string; + resourceType: ResourceMembershipResourceType; + resourceId: string; + state: ResourceMembershipState; + }): Promise { + assertBoardSelfMembershipAccess(input.actor, input.companyId, input.userId); + const decision = await evaluatePolicy(policyHook, input); + if (!decision.allowed) { + logger.warn( + { + companyId: input.companyId, + userId: input.userId, + resourceType: input.resourceType, + resourceId: input.resourceId, + reason: decision.reason ?? "denied", + source: decision.source ?? "policy_hook", + }, + "resource membership mutation denied", + ); + throw forbidden("Resource membership policy denied this request"); + } + return decision; + } + + return { + async listForUser(companyId: string, userId: string, actor: BoardActor): Promise { + assertBoardSelfMembershipAccess(actor, companyId, userId); + const [projectRows, agentRows] = await Promise.all([ + db + .select({ + projectId: projectMemberships.projectId, + state: projectMemberships.state, + updatedAt: projectMemberships.updatedAt, + }) + .from(projectMemberships) + .where(and( + eq(projectMemberships.companyId, companyId), + eq(projectMemberships.userId, userId), + )), + db + .select({ + agentId: agentMemberships.agentId, + state: agentMemberships.state, + updatedAt: agentMemberships.updatedAt, + }) + .from(agentMemberships) + .where(and( + eq(agentMemberships.companyId, companyId), + eq(agentMemberships.userId, userId), + )), + ]); + return { + projectMemberships: defaultJoinedMap(projectRows, "projectId"), + agentMemberships: defaultJoinedMap(agentRows, "agentId"), + updatedAt: latestDate( + ...projectRows.map((row) => row.updatedAt), + ...agentRows.map((row) => row.updatedAt), + ), + }; + }, + + async updateProject(input: { + companyId: string; + userId: string; + projectId: string; + state: ResourceMembershipState; + actor: BoardActor; + }): Promise { + const project = await db.query.projects.findFirst({ + where: and( + eq(projects.id, input.projectId), + eq(projects.companyId, input.companyId), + ), + }); + if (!project) throw notFound("Project not found"); + const decision = await assertMutationAllowed({ + actor: input.actor, + companyId: input.companyId, + userId: input.userId, + resourceType: "project", + resourceId: input.projectId, + state: input.state, + }); + + const existing = await db.query.projectMemberships.findFirst({ + where: and( + eq(projectMemberships.companyId, input.companyId), + eq(projectMemberships.userId, input.userId), + eq(projectMemberships.projectId, input.projectId), + ), + }); + const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined"; + if (previousState === input.state) { + return { + resourceType: "project", + resourceId: input.projectId, + state: input.state, + updatedAt: existing?.updatedAt ?? new Date(), + changed: false, + policySource: decision.source ?? "oss_default", + }; + } + + const now = new Date(); + const [row] = await db + .insert(projectMemberships) + .values({ + companyId: input.companyId, + projectId: input.projectId, + userId: input.userId, + state: input.state, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [projectMemberships.companyId, projectMemberships.userId, projectMemberships.projectId], + set: { + state: input.state, + updatedAt: now, + }, + }) + .returning(); + + return { + resourceType: "project", + resourceId: input.projectId, + state: row?.state === "left" ? "left" : "joined", + updatedAt: row?.updatedAt ?? now, + changed: true, + policySource: decision.source ?? "oss_default", + }; + }, + + async updateAgent(input: { + companyId: string; + userId: string; + agentId: string; + state: ResourceMembershipState; + actor: BoardActor; + }): Promise { + const agent = await db.query.agents.findFirst({ + where: and( + eq(agents.id, input.agentId), + eq(agents.companyId, input.companyId), + ), + }); + if (!agent) throw notFound("Agent not found"); + const decision = await assertMutationAllowed({ + actor: input.actor, + companyId: input.companyId, + userId: input.userId, + resourceType: "agent", + resourceId: input.agentId, + state: input.state, + }); + + const existing = await db.query.agentMemberships.findFirst({ + where: and( + eq(agentMemberships.companyId, input.companyId), + eq(agentMemberships.userId, input.userId), + eq(agentMemberships.agentId, input.agentId), + ), + }); + const previousState: ResourceMembershipState = existing?.state === "left" ? "left" : "joined"; + if (previousState === input.state) { + return { + resourceType: "agent", + resourceId: input.agentId, + state: input.state, + updatedAt: existing?.updatedAt ?? new Date(), + changed: false, + policySource: decision.source ?? "oss_default", + }; + } + + const now = new Date(); + const [row] = await db + .insert(agentMemberships) + .values({ + companyId: input.companyId, + agentId: input.agentId, + userId: input.userId, + state: input.state, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [agentMemberships.companyId, agentMemberships.userId, agentMemberships.agentId], + set: { + state: input.state, + updatedAt: now, + }, + }) + .returning(); + + return { + resourceType: "agent", + resourceId: input.agentId, + state: row?.state === "left" ? "left" : "joined", + updatedAt: row?.updatedAt ?? now, + changed: true, + policySource: decision.source ?? "oss_default", + }; + }, + }; +} diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index b280119a..80e22176 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -366,6 +366,8 @@ function createRoutineDispatchFingerprint(input: { payload: Record | null; projectId: string | null; assigneeAgentId: string | null; + routineRevisionId: string | null; + routineEnvFingerprint: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: Record | null; @@ -376,6 +378,11 @@ function createRoutineDispatchFingerprint(input: { return crypto.createHash("sha256").update(canonical).digest("hex"); } +function createRoutineEnvFingerprint(env: unknown) { + const canonical = JSON.stringify(normalizeRoutineDispatchFingerprintValue(env ?? null)); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + function readManagedRoutineIssueTemplate(defaultsJson: Record | null | undefined) { const value = defaultsJson?.issueTemplate; if (!isPlainRecord(value)) return null; @@ -406,6 +413,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], variables: routine.variables ?? [], + env: routine.env ?? null, }; } @@ -686,6 +694,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -719,6 +728,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, @@ -1138,6 +1148,8 @@ export function routineService( payload: triggerPayload, projectId, assigneeAgentId, + routineRevisionId: input.routine.latestRevisionId, + routineEnvFingerprint: createRoutineEnvFingerprint(input.routine.env), executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -1183,6 +1195,7 @@ export function routineService( idempotencyKey: input.idempotencyKey ?? null, triggerPayload, dispatchFingerprint, + routineRevisionId: input.routine.latestRevisionId, }) .returning(); @@ -1430,6 +1443,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -1462,6 +1476,7 @@ export function routineService( idempotencyKey: run.idempotencyKey, triggerPayload: run.triggerPayload as Record | null, dispatchFingerprint: run.dispatchFingerprint, + routineRevisionId: run.routineRevisionId, linkedIssueId: run.linkedIssueId, coalescedIntoRunId: run.coalescedIntoRunId, failureReason: run.failureReason, @@ -1508,13 +1523,19 @@ export function routineService( await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); + const env = input.env === undefined || input.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(companyId, input.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const variables = syncRoutineVariablesWithTemplate( [input.title, input.description], sanitizeRoutineVariableInputs(input.variables), ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - return db.transaction(async (tx) => { + const createdRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; const [created] = await txDb .insert(routines) @@ -1531,6 +1552,7 @@ export function routineService( concurrencyPolicy: input.concurrencyPolicy, catchUpPolicy: input.catchUpPolicy, variables, + env, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, updatedByAgentId: actor.agentId ?? null, @@ -1540,8 +1562,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, created, actor, { changeSummary: "Created routine", }); + if (env) { + await secretsSvc.syncEnvBindingsForTarget( + companyId, + { targetType: "routine", targetId: routine.id }, + env, + { db: tx }, + ); + } return routine; }); + return createdRoutine; }, update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise => { @@ -1551,6 +1582,14 @@ export function routineService( const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; + const nextEnv = patch.env === undefined + ? existing.env + : patch.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, patch.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const requestedStatus = patch.status ?? existing.status; if (patch.status === "active") { assertRoutineCanEnable(patch.status, nextAssigneeAgentId); @@ -1582,7 +1621,7 @@ export function routineService( if (enabledScheduleTriggers) { assertScheduleCompatibleVariables(nextVariables); } - return db.transaction(async (tx) => { + const updatedRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`); const locked = await txDb @@ -1611,6 +1650,7 @@ export function routineService( concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, variables: nextVariables, + env: nextEnv, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, }; @@ -1633,6 +1673,14 @@ export function routineService( ) .then((rows) => rows[0] ?? null); if (latestRevision && snapshotsMatch(nextSnapshot, latestRevision.snapshot as RoutineRevisionSnapshotV1)) { + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + candidate.env, + { db: tx }, + ); + } return locked; } } @@ -1651,6 +1699,7 @@ export function routineService( concurrencyPolicy: candidate.concurrencyPolicy, catchUpPolicy: candidate.catchUpPolicy, variables: candidate.variables, + env: candidate.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: new Date(), @@ -1661,8 +1710,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, updated, actor, { changeSummary: "Updated routine", }); + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + routine.companyId, + { targetType: "routine", targetId: routine.id }, + routine.env, + { db: tx }, + ); + } return routine; }); + return updatedRoutine; }, createTrigger: async ( @@ -1770,7 +1828,7 @@ export function routineService( } } - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); const [updated] = await txDb @@ -1801,12 +1859,13 @@ export function routineService( }); return { trigger: updated as RoutineTrigger, revision: appended.revision }; }); + return result; }, deleteTrigger: async (id: string, actor: Actor = {}): Promise<{ deleted: boolean; revision: RoutineRevision | null }> => { const existing = await getTriggerById(id); if (!existing) return { deleted: false, revision: null }; - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id)); @@ -1821,6 +1880,7 @@ export function routineService( }); return { deleted: true, revision: appended.revision }; }); + return result; }, rotateTriggerSecret: async ( @@ -1912,7 +1972,7 @@ export function routineService( const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb @@ -1964,6 +2024,7 @@ export function routineService( concurrencyPolicy: routineSnapshot.concurrencyPolicy, catchUpPolicy: routineSnapshot.catchUpPolicy, variables: routineSnapshot.variables, + env: routineSnapshot.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: now, @@ -2033,6 +2094,12 @@ export function routineService( changeSummary: `Restored from revision ${targetRevision.revisionNumber}`, restoredFromRevisionId: targetRevision.id, }); + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + routineSnapshot.env, + { db: tx }, + ); return { routine: appended.routine, revision: appended.revision, @@ -2041,6 +2108,7 @@ export function routineService( secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial), }; }); + return result; }, runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => { @@ -2172,6 +2240,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -2204,6 +2273,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 17f13cef..327b250e 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -20,6 +20,7 @@ import type { RemoteSecretImportCandidate, RemoteSecretImportConflict, RemoteSecretImportRowResult, + SecretProviderConfigDiscoveryPreviewResult, SecretBindingTargetType, SecretProvider, SecretProviderConfigHealthResponse, @@ -34,6 +35,7 @@ import { isUuidLike, normalizeAgentUrlKey, secretProviderConfigPayloadSchema, + secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; @@ -61,6 +63,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); +type DbTransaction = Parameters[0]>[0]; +type SecretBindingDb = Pick; function remoteProviderHttpError(error: unknown, context: { companyId: string; @@ -195,6 +199,14 @@ type RuntimeSecretResolution = { manifestEntry: RuntimeSecretManifestEntry; }; +type SecretResolutionErrorCode = + | "binding_missing" + | "secret_deleted" + | "secret_inactive" + | "version_missing" + | "version_inactive" + | "provider_error"; + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -238,6 +250,33 @@ function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderCo return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } +function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { + if (isSecretProviderClientError(error)) return "provider_error"; + if (error instanceof HttpError) { + const details = asRecord(error.details); + switch (details?.code) { + case "binding_missing": + case "secret_deleted": + case "secret_inactive": + case "version_missing": + case "version_inactive": + case "provider_error": + return details.code; + } + if (error.message === "Secret is not active") return "secret_inactive"; + if (error.message === "Secret version not found") return "version_missing"; + if (error.message === "Secret version is not active") return "version_inactive"; + if ( + error.message === "Secret resolution requires a binding config path" || + error.message.startsWith("Secret is not bound to ") + ) { + return "binding_missing"; + } + if (error.status >= 500) return "provider_error"; + } + return "provider_error"; +} + function assertSelectableProviderConfig(config: { provider: string; status: string; @@ -259,8 +298,8 @@ export function secretService(db: Db) { fieldPath?: string; }; - async function getById(id: string) { - return db + async function getById(id: string, source: Pick = db) { + return source .select() .from(companySecrets) .where(eq(companySecrets.id, id)) @@ -321,7 +360,7 @@ export function secretService(db: Db) { ) { if (!context) return; if (!context.configPath) { - throw unprocessable("Secret resolution requires a binding config path"); + throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" }); } const binding = await getBinding({ companyId, @@ -333,6 +372,7 @@ export function secretService(db: Db) { if (!binding) { throw unprocessable( `Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`, + { code: "binding_missing" }, ); } } @@ -365,8 +405,12 @@ export function secretService(db: Db) { }); } - async function assertSecretInCompany(companyId: string, secretId: string) { - const secret = await getById(secretId); + async function assertSecretInCompany( + companyId: string, + secretId: string, + source: Pick = db, + ) { + const secret = await getById(secretId, source); if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); @@ -429,6 +473,19 @@ export function secretService(db: Db) { return parsed.data.config; } + function toDraftProviderVaultRuntimeConfig(input: { + companyId: string; + provider: SecretProvider; + config: Record; + }): SecretProviderVaultRuntimeConfig { + return { + id: `discovery-preview-${input.companyId}`, + provider: input.provider, + status: "ready", + config: validateProviderConfigPayload(input.provider, input.config), + }; + } + function providerConfigHealth(input: { id: string; provider: SecretProvider; @@ -495,19 +552,24 @@ export function secretService(db: Db) { version: number | "latest", context?: SecretConsumerContext, ): Promise { - const secret = await assertSecretInCompany(companyId, secretId); + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; const configPath = context?.configPath ?? null; try { + if (secret.status === "deleted") { + throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); + } if (secret.status !== "active") { - throw unprocessable("Secret is not active"); + throw unprocessable("Secret is not active", { code: "secret_inactive" }); } await assertBindingContext(companyId, secret.id, context); const versionRow = await getSecretVersion(secret.id, resolvedVersion); - if (!versionRow) throw notFound("Secret version not found"); + if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { - throw unprocessable("Secret version is not active"); + throw unprocessable("Secret version is not active", { code: "version_inactive" }); } const provider = getSecretProvider(providerId); const providerConfig = await getSelectableRuntimeProviderConfig({ @@ -555,7 +617,7 @@ export function secretService(db: Db) { }, }; } catch (err) { - const errorCode = err instanceof Error ? err.message.slice(0, 120) : "resolution_failed"; + const errorCode = secretResolutionErrorCode(err); await recordAccessEvent({ companyId, secretId: secret.id, @@ -902,6 +964,54 @@ export function secretService(db: Db) { checkProviders: () => checkSecretProviders(), + previewProviderConfigDiscovery: async ( + companyId: string, + input: { + provider: SecretProvider; + config?: Record; + query?: string | null; + nextToken?: string | null; + pageSize?: number; + }, + ): Promise => { + const parsed = secretProviderConfigDiscoveryPreviewSchema.safeParse({ + provider: input.provider, + config: input.config ?? {}, + query: input.query, + nextToken: input.nextToken, + pageSize: input.pageSize, + }); + if (!parsed.success) { + throw unprocessable("Invalid provider vault discovery config", parsed.error.flatten()); + } + const providerId = parsed.data.provider as SecretProvider; + const provider = getSecretProvider(providerId); + if (!provider.discoverProviderConfigs) { + throw unprocessable(`${providerId} provider does not support provider vault discovery`); + } + const runtimeConfig = toDraftProviderVaultRuntimeConfig({ + companyId, + provider: providerId, + config: parsed.data.config, + }); + try { + return await provider.discoverProviderConfigs({ + companyId, + providerConfig: runtimeConfig, + query: parsed.data.query, + nextToken: parsed.data.nextToken, + pageSize: parsed.data.pageSize, + }); + } catch (error) { + throw remoteProviderHttpError(error, { + companyId, + provider: providerId, + providerConfigId: "discovery-preview", + operation: "secret_provider_config.discovery.preview", + }); + } + }, + listProviderConfigs: (companyId: string) => db .select() @@ -1024,6 +1134,13 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); }, + removeProviderConfig: async (id: string) => + db + .delete(companySecretProviderConfigs) + .where(eq(companySecretProviderConfigs.id, id)) + .returning() + .then((rows) => rows[0] ?? null), + setDefaultProviderConfig: async (id: string) => { const existing = await getProviderConfigById(id); if (!existing) return null; @@ -1984,6 +2101,7 @@ export function secretService(db: Db) { companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, envValue: unknown, + options?: { db?: SecretBindingDb }, ) => { const record = asRecord(envValue) ?? {}; const refs: Array<{ @@ -1992,12 +2110,13 @@ export function secretService(db: Db) { versionSelector: SecretVersionSelector; }> = []; const pathPrefix = target.pathPrefix ?? "env"; + const bindingDb = options?.db ?? db; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") continue; - await assertSecretInCompany(companyId, binding.secretId); + await assertSecretInCompany(companyId, binding.secretId, bindingDb); refs.push({ secretId: binding.secretId, configPath: `${pathPrefix}.${key}`, @@ -2005,8 +2124,8 @@ export function secretService(db: Db) { }); } - await db.transaction(async (tx) => { - await tx + const writeBindings = async (targetDb: SecretBindingDb) => { + await targetDb .delete(companySecretBindings) .where( and( @@ -2017,7 +2136,7 @@ export function secretService(db: Db) { ), ); if (refs.length === 0) return; - await tx.insert(companySecretBindings).values( + await targetDb.insert(companySecretBindings).values( refs.map((ref) => ({ companyId, secretId: ref.secretId, @@ -2028,7 +2147,13 @@ export function secretService(db: Db) { required: true, })), ); - }); + }; + + if (options?.db) { + await writeBindings(options.db); + } else { + await db.transaction(async (tx) => writeBindings(tx)); + } return refs; }, diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts new file mode 100644 index 00000000..13fa592c --- /dev/null +++ b/server/src/static-index-html.ts @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; +import { applyUiBranding } from "./ui-branding.js"; + +export function readBrandedStaticIndexHtml(uiDist: string): string { + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); +} diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index d4f3ccb1..3c230664 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -115,17 +115,23 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); const persistedEnv = readEnvEntries(envPath); + const persistedConfigPath = nonEmpty(persistedEnv.PAPERCLIP_CONFIG); + const persistedConfigLooksStale = + persistedConfigPath !== null && + path.resolve(expandHomePrefix(persistedConfigPath)) !== path.resolve(configPath) && + !fs.existsSync(resolveHomeAwarePath(persistedConfigPath)); + const stablePersistedEnv = persistedConfigLooksStale ? {} : persistedEnv; const worktreeRoot = path.resolve(path.dirname(configPath), ".."); const worktreeName = - nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); const instanceId = - nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_INSTANCE_ID) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(stablePersistedEnv.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 3e6e2444..8095c770 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -119,7 +119,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(`/${companyPrefix}/issues`); await expect(page.locator(issueLinkSelector)).toBeVisible(); - await expect(page.locator(issueLinkSelector)).toContainText("Planning"); + await expect(page.locator(issueLinkSelector)).not.toContainText("Planning"); await page.screenshot({ path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`, fullPage: true, @@ -149,7 +149,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(`/${companyPrefix}/issues`); await expect(page.locator(issueLinkSelector)).toBeVisible(); - await expect(page.locator(issueLinkSelector)).toContainText("Planning"); + await expect(page.locator(issueLinkSelector)).not.toContainText("Planning"); await page.screenshot({ path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`, fullPage: true, diff --git a/ui/index.html b/ui/index.html index c41e22f2..0625148a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,9 +4,6 @@ - - - Paperclip diff --git a/ui/package.json b/ui/package.json index 65caa25e..4c3acec4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-cursor-cloud": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", + "@paperclipai/adapter-grok-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", @@ -52,12 +53,14 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "hermes-paperclip-adapter": "^0.2.0", + "i18next": "^26.1.0", "lexical": "0.35.0", "lucide-react": "^0.574.0", "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.7", "react-markdown": "^10.1.0", "react-router-dom": "^7.1.5", "remark-gfm": "^4.0.1", diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest index 907f6293..ea5a8bc9 100644 --- a/ui/public/site.webmanifest +++ b/ui/public/site.webmanifest @@ -5,7 +5,7 @@ "description": "AI-powered project management and agent coordination platform", "start_url": "/", "scope": "/", - "display": "standalone", + "display": "browser", "orientation": "any", "theme_color": "#18181b", "background_color": "#18181b", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 09161d09..f6d81f2b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; import { CloudAccessGate } from "./components/CloudAccessGate"; @@ -29,7 +30,10 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; -import { CompanyAccess } from "./pages/CompanyAccess"; +import { CloudUpstream } from "./pages/CloudUpstream"; +import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; +import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; +import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; import { Secrets } from "./pages/Secrets"; @@ -68,11 +72,15 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> @@ -245,16 +253,21 @@ function UnprefixedBoardRedirect() { function NoCompaniesStartPage() { const { openOnboarding } = useDialogActions(); + const { t } = useTranslation(); return (
-

Create your first company

+

+ {t("app.noCompanies.title", { defaultValue: "Create your first company" })} +

- Get started by creating a company. + {t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}

- +
@@ -270,6 +283,7 @@ export function App() { } /> } /> } /> + } /> }> } /> diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 948a3c8d..e710d07b 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -78,6 +78,11 @@ const adapterDisplayMap: Record = { description: "Local Gemini agent", icon: Gem, }, + grok_local: { + label: "Grok Build", + description: "Local Grok Build agent", + icon: Bot, + }, opencode_local: { label: "OpenCode", description: "Local multi-provider agent", @@ -105,10 +110,11 @@ const adapterDisplayMap: Record = { }, openclaw_gateway: { label: "OpenClaw Gateway", - description: "Invoke OpenClaw via gateway protocol", + description: "External gateway adapter", icon: Bot, comingSoon: true, - disabledLabel: "Configure OpenClaw within the App", + disabledLabel: "Invite external agents from the add-agent modal", + hideFromVisualSelection: true, }, process: { label: "Process", diff --git a/ui/src/adapters/grok-local/config-fields.tsx b/ui/src/adapters/grok-local/config-fields.tsx new file mode 100644 index 00000000..13ab66a0 --- /dev/null +++ b/ui/src/adapters/grok-local/config-fields.tsx @@ -0,0 +1,51 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + DraftInput, + Field, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Paperclip stages it into the Grok workspace as Agents.md when possible."; + +export function GrokLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + <> + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ + ); +} diff --git a/ui/src/adapters/grok-local/index.ts b/ui/src/adapters/grok-local/index.ts new file mode 100644 index 00000000..31b7ffe2 --- /dev/null +++ b/ui/src/adapters/grok-local/index.ts @@ -0,0 +1,13 @@ +import type { UIAdapterModule } from "../types"; +import { createGrokStdoutParser, parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui"; +import { buildGrokLocalConfig } from "@paperclipai/adapter-grok-local/ui"; +import { GrokLocalConfigFields } from "./config-fields"; + +export const grokLocalUIAdapter: UIAdapterModule = { + type: "grok_local", + label: "Grok Build (local)", + parseStdoutLine: parseGrokStdoutLine, + createStdoutParser: createGrokStdoutParser, + ConfigFields: GrokLocalConfigFields, + buildAdapterConfig: buildGrokLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 0e2f27a3..9b2476d3 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -5,6 +5,7 @@ import { codexLocalUIAdapter } from "./codex-local"; import { cursorCloudUIAdapter } from "./cursor-cloud"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; +import { grokLocalUIAdapter } from "./grok-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -56,6 +57,7 @@ function registerBuiltInUIAdapters() { codexLocalUIAdapter, cursorCloudUIAdapter, geminiLocalUIAdapter, + grokLocalUIAdapter, hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index ddd164a5..56627f32 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; +import { grokLocalUIAdapter } from "./grok-local"; import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { @@ -182,4 +183,20 @@ describe("buildTranscript", () => { }, ]); }); + + it("coalesces grok_local streaming text fragments into one assistant entry", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "Hello " })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "world" })}\n` }, + { ts, stream: "stdout", chunk: `${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" })}\n` }, + ], + grokLocalUIAdapter, + ); + + expect(entries).toEqual([ + { kind: "assistant", ts, text: "Hello world", delta: true }, + { kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" }, + ]); + }); }); diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 786776fe..89f2c2b6 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -21,6 +21,7 @@ const KNOWN_DEFAULTS: Record = { codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, diff --git a/ui/src/api/cloudUpstreams.ts b/ui/src/api/cloudUpstreams.ts new file mode 100644 index 00000000..48ddff5c --- /dev/null +++ b/ui/src/api/cloudUpstreams.ts @@ -0,0 +1,40 @@ +import type { + CloudUpstreamActivationEntityType, + CloudUpstreamConnectStartResponse, + CloudUpstreamConnection, + CloudUpstreamPreview, + CloudUpstreamRun, + CloudUpstreamsState, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const cloudUpstreamsApi = { + list: (companyId: string) => + api.get(`/cloud-upstreams?companyId=${encodeURIComponent(companyId)}`), + startConnect: (input: { companyId: string; remoteUrl: string; redirectUri: string }) => + api.post("/cloud-upstreams/connect/start", input), + finishConnect: (input: { pendingConnectionId: string; code: string; state: string }) => + api.post("/cloud-upstreams/connect/finish", input), + preview: (connectionId: string, input: { companyId: string }) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/preview`, input), + createRun: (connectionId: string, input: { companyId: string; retryOfRunId?: string | null }) => + api.post(`/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs`, input ?? {}), + getRun: (connectionId: string, runId: string, companyId: string) => + api.get( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}?companyId=${encodeURIComponent(companyId)}`, + ), + cancelRun: (connectionId: string, runId: string, input: { companyId: string }) => + api.post( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/cancel`, + input, + ), + activateEntities: ( + connectionId: string, + runId: string, + input: { companyId: string; entityType: CloudUpstreamActivationEntityType }, + ) => + api.post( + `/cloud-upstreams/${encodeURIComponent(connectionId)}/push-runs/${encodeURIComponent(runId)}/activation`, + input, + ), +}; diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts new file mode 100644 index 00000000..c01c481a --- /dev/null +++ b/ui/src/api/companies-query.ts @@ -0,0 +1,25 @@ +import type { Company } from "@paperclipai/shared"; +import { companiesApi } from "./companies"; +import { ApiError } from "./client"; +import { queryKeys } from "../lib/queryKeys"; + +export type CompanyListResult = { companies: Company[]; unauthorized: boolean }; + +// Single source of truth for the `["companies"]` query. Both CompanyProvider and +// the invite landing page read this cache entry, so they must agree on the shape — +// returning a bare `Company[]` from one and this wrapped object from the other +// silently corrupts the shared cache and crashes whichever reads the other's shape. +export const companiesListQueryOptions = { + queryKey: queryKeys.companies.all, + queryFn: async (): Promise => { + try { + return { companies: await companiesApi.list(), unauthorized: false }; + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + return { companies: [], unauthorized: true }; + } + throw err; + } + }, + retry: false, +} as const; diff --git a/ui/src/api/execution-workspaces.test.ts b/ui/src/api/execution-workspaces.test.ts index b18bd650..ba91f666 100644 --- a/ui/src/api/execution-workspaces.test.ts +++ b/ui/src/api/execution-workspaces.test.ts @@ -26,4 +26,5 @@ describe("executionWorkspacesApi.listSummaries", () => { "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", ); }); + }); diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index e2725b20..453662c5 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -38,4 +38,15 @@ export const healthApi = { } return res.json(); }, + requestDevServerRestart: async (): Promise => { + const res = await fetch("/api/health/dev-server/restart", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + const payload = await res.json().catch(() => null) as { error?: string } | null; + throw new Error(payload?.error ?? `Failed to request restart (${res.status})`); + } + }, }; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 5a73a7d1..7da3d553 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -16,5 +16,6 @@ export { heartbeatsApi } from "./heartbeats"; export { instanceSettingsApi } from "./instanceSettings"; export { sidebarBadgesApi } from "./sidebarBadges"; export { sidebarPreferencesApi } from "./sidebarPreferences"; +export { resourceMembershipsApi } from "./resourceMemberships"; export { inboxDismissalsApi } from "./inboxDismissals"; export { companySkillsApi } from "./companySkills"; diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 4013f67d..56bdcf47 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -51,6 +51,18 @@ describe("issuesApi.list", () => { ); }); + it("passes issue list sort options through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { + limit: 500, + sortField: "updated", + sortDir: "desc", + }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?limit=500&sortField=updated&sortDir=desc", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 389699a4..2e35416a 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -60,6 +60,8 @@ export const issuesApi = { q?: string; limit?: number; offset?: number; + sortField?: "updated"; + sortDir?: "asc" | "desc"; }, ) => { const params = new URLSearchParams(); @@ -86,6 +88,8 @@ export const issuesApi = { if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); + if (filters?.sortField) params.set("sortField", filters.sortField); + if (filters?.sortDir) params.set("sortDir", filters.sortDir); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, @@ -131,7 +135,7 @@ export const issuesApi = { data: { actionId?: string; outcome: "restored" | "false_positive" | "blocked" | "cancelled"; - sourceIssueStatus: "done" | "in_review" | "blocked"; + sourceIssueStatus: "todo" | "done" | "in_review" | "blocked"; resolutionNote?: string | null; }, ) => api.post(`/issues/${id}/recovery-actions/resolve`, data), @@ -259,6 +263,10 @@ export const issuesApi = { getDocument: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}`), upsertDocument: (id: string, key: string, data: UpsertIssueDocument) => api.put(`/issues/${id}/documents/${encodeURIComponent(key)}`, data), + lockDocument: (id: string, key: string) => + api.post(`/issues/${id}/documents/${encodeURIComponent(key)}/lock`, {}), + unlockDocument: (id: string, key: string) => + api.post(`/issues/${id}/documents/${encodeURIComponent(key)}/unlock`, {}), listDocumentRevisions: (id: string, key: string) => api.get(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`), restoreDocumentRevision: (id: string, key: string, revisionId: string) => diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 2e4c981a..9df0b937 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -138,7 +138,7 @@ export interface AvailablePluginExample { displayName: string; description: string; localPath: string; - tag: "example"; + tag: "example" | "first-party"; } export interface PluginLocalFolderProblem { diff --git a/ui/src/api/resourceMemberships.ts b/ui/src/api/resourceMemberships.ts new file mode 100644 index 00000000..56079837 --- /dev/null +++ b/ui/src/api/resourceMemberships.ts @@ -0,0 +1,21 @@ +import type { + ResourceMemberships, + ResourceMembershipUpdateResult, + UpdateResourceMembership, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const resourceMembershipsApi = { + listMine: (companyId: string) => + api.get(`/companies/${companyId}/resource-memberships/me`), + updateProject: (companyId: string, projectId: string, data: UpdateResourceMembership) => + api.put( + `/companies/${companyId}/resource-memberships/me/projects/${projectId}`, + data, + ), + updateAgent: (companyId: string, agentId: string, data: UpdateResourceMembership) => + api.put( + `/companies/${companyId}/resource-memberships/me/agents/${agentId}`, + data, + ), +}; diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index aedfa85d..f04705f3 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -2,6 +2,7 @@ import type { CompanySecret, CompanySecretUsageBinding, CompanySecretProviderConfig, + SecretProviderConfigDiscoveryPreviewResult, RemoteSecretImportPreviewResult, RemoteSecretImportResult, SecretAccessEvent, @@ -95,6 +96,14 @@ export interface RemoteImportInput { secrets: RemoteImportSelectionInput[]; } +export interface SecretProviderConfigDiscoveryPreviewInput { + provider: SecretProvider; + config?: Record; + query?: string | null; + nextToken?: string | null; + pageSize?: number; +} + export const secretsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/secrets`), providers: (companyId: string) => @@ -103,11 +112,21 @@ export const secretsApi = { api.get(`/companies/${companyId}/secret-providers/health`), providerConfigs: (companyId: string) => api.get(`/companies/${companyId}/secret-provider-configs`), + providerConfigDiscoveryPreview: ( + companyId: string, + data: SecretProviderConfigDiscoveryPreviewInput, + ) => + api.post( + `/companies/${companyId}/secret-provider-configs/discovery/preview`, + data, + ), createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) => api.post(`/companies/${companyId}/secret-provider-configs`, data), updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) => api.patch(`/secret-provider-configs/${id}`, data), disableProviderConfig: (id: string) => + api.patch(`/secret-provider-configs/${id}`, { status: "disabled" }), + removeProviderConfig: (id: string) => api.delete(`/secret-provider-configs/${id}`), setDefaultProviderConfig: (id: string) => api.post(`/secret-provider-configs/${id}/default`, {}), diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 429d0812..124725bb 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment jsdom -import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,6 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ @@ -61,14 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + await callback(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + async function flushReact() { - await act(async () => { + for (let i = 0; i < 3; i += 1) { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); + } } describe("CompanySettingsSidebar", () => { @@ -83,6 +100,17 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: false, + }); }); afterEach(() => { @@ -110,7 +138,9 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); expect(container.textContent).toContain("Environments"); - expect(container.textContent).toContain("Access"); + expect(container.textContent).not.toContain("Cloud upstream"); + expect(container.textContent).toContain("Members"); + expect(container.textContent).not.toContain("Cloud upstream"); expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Secrets"); expect(sidebarNavItemMock).toHaveBeenCalledWith( @@ -129,8 +159,8 @@ describe("CompanySettingsSidebar", () => { ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ - to: "/company/settings/access", - label: "Access", + to: "/company/settings/members", + label: "Members", badge: 2, end: true, }), @@ -154,4 +184,114 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); + + it("renders company settings pages contributed by ready plugins", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + exportName: "PermissionsPage", + routePath: "permissions", + pluginId: "plugin-1", + pluginKey: "permissions-extension", + pluginDisplayName: "Permissions Extension", + pluginVersion: "0.1.0", + }, + ], + isLoading: false, + errorMessage: null, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Permissions"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/permissions", + label: "Permissions", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); + + it("shows cloud upstream only when cloud sync is enabled", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableCloudSync: true, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Cloud upstream"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/cloud-upstream", + label: "Cloud upstream", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 027f2741..9aab3f99 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,16 +1,23 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, CloudUpload, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { instanceSettingsApi } from "@/api/instanceSettings"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; +import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; export function CompanySettingsSidebar() { const { selectedCompany, selectedCompanyId } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); + const { slots: companySettingsPluginSlots } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); const { data: badges } = useQuery({ queryKey: selectedCompanyId ? queryKeys.sidebarBadges(selectedCompanyId) @@ -29,6 +36,11 @@ export function CompanySettingsSidebar() { retry: false, refetchInterval: 15_000, }); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const showCloudUpstream = experimentalSettings?.enableCloudSync === true; return (