diff --git a/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md similarity index 100% rename from skills/create-agent-adapter/SKILL.md rename to .agents/skills/create-agent-adapter/SKILL.md diff --git a/.agents/skills/doc-maintenance/SKILL.md b/.agents/skills/doc-maintenance/SKILL.md new file mode 100644 index 00000000..a597e90c --- /dev/null +++ b/.agents/skills/doc-maintenance/SKILL.md @@ -0,0 +1,201 @@ +--- +name: doc-maintenance +description: > + Audit top-level documentation (README, SPEC, PRODUCT) against recent git + history to find drift — shipped features missing from docs or features + listed as upcoming that already landed. Proposes minimal edits, creates + a branch, and opens a PR. Use when asked to review docs for accuracy, + after major feature merges, or on a periodic schedule. +--- + +# Doc Maintenance Skill + +Detect documentation drift and fix it via PR — no rewrites, no churn. + +## When to Use + +- Periodic doc review (e.g. weekly or after releases) +- After major feature merges +- When asked "are our docs up to date?" +- When asked to audit README / SPEC / PRODUCT accuracy + +## Target Documents + +| Document | Path | What matters | +|----------|------|-------------| +| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table | +| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy | +| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy | + +Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files, +release notes. These are dev-facing or ephemeral — lower risk of user-facing +confusion. + +## Workflow + +### Step 1 — Detect what changed + +Find the last review cursor: + +```bash +# Read the last-reviewed commit SHA +CURSOR_FILE=".doc-review-cursor" +if [ -f "$CURSOR_FILE" ]; then + LAST_SHA=$(cat "$CURSOR_FILE" | head -1) +else + # First run: look back 60 days + LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1) +fi +``` + +Then gather commits since the cursor: + +```bash +git log "$LAST_SHA"..HEAD --oneline --no-merges +``` + +### Step 2 — Classify changes + +Scan commit messages and changed files. Categorize into: + +- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`) +- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`) +- **Structural** — new directories, config changes, new adapters, new CLI commands + +**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only +changes, style/formatting commits. These don't affect doc accuracy. + +For borderline cases, check the actual diff — a commit titled "refactor: X" +that adds a new public API is a feature. + +### Step 3 — Build a change summary + +Produce a concise list like: + +``` +Since last review (, ): +- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge) +- FEATURE: Project archiving added +- BREAKING: Removed legacy webhook adapter +- STRUCTURAL: New .agents/skills/ directory convention +``` + +If there are no notable changes, skip to Step 7 (update cursor and exit). + +### Step 4 — Audit each target doc + +For each target document, read it fully and cross-reference against the change +summary. Check for: + +1. **False negatives** — major shipped features not mentioned at all +2. **False positives** — features listed as "coming soon" / "roadmap" / "planned" + / "not supported" / "TBD" that already shipped +3. **Quickstart accuracy** — install commands, prereqs, and startup instructions + still correct (README only) +4. **Feature table accuracy** — does the features section reflect current + capabilities? (README only) +5. **Works-with accuracy** — are supported adapters/integrations listed correctly? + +Use `references/audit-checklist.md` as the structured checklist. +Use `references/section-map.md` to know where to look for each feature area. + +### Step 5 — Create branch and apply minimal edits + +```bash +# Create a branch for the doc updates +BRANCH="docs/maintenance-$(date +%Y%m%d)" +git checkout -b "$BRANCH" +``` + +Apply **only** the edits needed to fix drift. Rules: + +- **Minimal patches only.** Fix inaccuracies, don't rewrite sections. +- **Preserve voice and style.** Match the existing tone of each document. +- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize + sections unless they're part of a factual fix. +- **No new sections.** If a feature needs a whole new section, note it in the + PR description as a follow-up — don't add it in a maintenance pass. +- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention + in the appropriate existing section if there isn't one already. Don't add + long descriptions. + +### Step 6 — Open a PR + +Commit the changes and open a PR: + +```bash +git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor +git commit -m "docs: update documentation for accuracy + +- [list each fix briefly] + +Co-Authored-By: Paperclip " + +git push -u origin "$BRANCH" + +gh pr create \ + --title "docs: periodic documentation accuracy update" \ + --body "$(cat <<'EOF' +## Summary +Automated doc maintenance pass. Fixes documentation drift detected since +last review. + +### Changes +- [list each fix] + +### Change summary (since last review) +- [list notable code changes that triggered doc updates] + +## Review notes +- Only factual accuracy fixes — no style/cosmetic changes +- Preserves existing voice and structure +- Larger doc additions (new sections, tutorials) noted as follow-ups + +🤖 Generated by doc-maintenance skill +EOF +)" +``` + +### Step 7 — Update the cursor + +After a successful audit (whether or not edits were needed), update the cursor: + +```bash +git rev-parse HEAD > .doc-review-cursor +``` + +If edits were made, this is already committed in the PR branch. If no edits +were needed, commit the cursor update to the current branch. + +## Change Classification Rules + +| Signal | Category | Doc update needed? | +|--------|----------|-------------------| +| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing | +| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes | +| New top-level directory or config file | Structural | Maybe | +| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) | +| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No | +| `docs:` | Doc change | No (already handled) | +| Dependency bumps only | Maintenance | No | + +## Patch Style Guide + +- Fix the fact, not the prose +- If removing a roadmap item, don't leave a gap — remove the bullet cleanly +- If adding a feature mention, match the format of surrounding entries + (e.g. if features are in a table, add a table row) +- Keep README changes especially minimal — it shouldn't churn often +- For SPEC/PRODUCT, prefer updating existing statements over adding new ones + (e.g. change "not supported in V1" to "supported via X" rather than adding + a new section) + +## Output + +When the skill completes, report: + +- How many commits were scanned +- How many notable changes were found +- How many doc edits were made (and to which files) +- PR link (if edits were made) +- Any follow-up items that need larger doc work diff --git a/.agents/skills/doc-maintenance/references/audit-checklist.md b/.agents/skills/doc-maintenance/references/audit-checklist.md new file mode 100644 index 00000000..9c13a437 --- /dev/null +++ b/.agents/skills/doc-maintenance/references/audit-checklist.md @@ -0,0 +1,85 @@ +# Doc Maintenance Audit Checklist + +Use this checklist when auditing each target document. For each item, compare +against the change summary from git history. + +## README.md + +### Features table +- [ ] Each feature card reflects a shipped capability +- [ ] No feature cards for things that don't exist yet +- [ ] No major shipped features missing from the table + +### Roadmap +- [ ] Nothing listed as "planned" or "coming soon" that already shipped +- [ ] No removed/cancelled items still listed +- [ ] Items reflect current priorities (cross-check with recent PRs) + +### Quickstart +- [ ] `npx paperclipai onboard` command is correct +- [ ] Manual install steps are accurate (clone URL, commands) +- [ ] Prerequisites (Node version, pnpm version) are current +- [ ] Server URL and port are correct + +### "What is Paperclip" section +- [ ] High-level description is accurate +- [ ] Step table (Define goal / Hire team / Approve and run) is correct + +### "Works with" table +- [ ] All supported adapters/runtimes are listed +- [ ] No removed adapters still listed +- [ ] Logos and labels match current adapter names + +### "Paperclip is right for you if" +- [ ] Use cases are still accurate +- [ ] No claims about capabilities that don't exist + +### "Why Paperclip is special" +- [ ] Technical claims are accurate (atomic execution, governance, etc.) +- [ ] No features listed that were removed or significantly changed + +### FAQ +- [ ] Answers are still correct +- [ ] No references to removed features or outdated behavior + +### Development section +- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.) +- [ ] Link to DEVELOPING.md is correct + +## doc/SPEC.md + +### Company Model +- [ ] Fields match current schema +- [ ] Governance model description is accurate + +### Agent Model +- [ ] Adapter types match what's actually supported +- [ ] Agent configuration description is accurate +- [ ] No features described as "not supported" or "not V1" that shipped + +### Task Model +- [ ] Task hierarchy description is accurate +- [ ] Status values match current implementation + +### Extensions / Plugins +- [ ] If plugins are shipped, no "not in V1" or "future" language +- [ ] Plugin model description matches implementation + +### Open Questions +- [ ] Resolved questions removed or updated +- [ ] No "TBD" items that have been decided + +## doc/PRODUCT.md + +### Core Concepts +- [ ] Company, Employees, Task Management descriptions accurate +- [ ] Agent Execution modes described correctly +- [ ] No missing major concepts + +### Principles +- [ ] Principles haven't been contradicted by shipped features +- [ ] No principles referencing removed capabilities + +### User Flow +- [ ] Dream scenario still reflects actual onboarding +- [ ] Steps are achievable with current features diff --git a/.agents/skills/doc-maintenance/references/section-map.md b/.agents/skills/doc-maintenance/references/section-map.md new file mode 100644 index 00000000..4ec64f83 --- /dev/null +++ b/.agents/skills/doc-maintenance/references/section-map.md @@ -0,0 +1,22 @@ +# Section Map + +Maps feature areas to specific document sections so the skill knows where to +look when a feature ships or changes. + +| Feature Area | README Section | SPEC Section | PRODUCT Section | +|-------------|---------------|-------------|----------------| +| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts | +| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution | +| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles | +| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) | +| Task Management | Features table | Task Model | Task Management | +| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents | +| Multi-Company | Features table, FAQ | Company Model | Company | +| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution | +| CLI Commands | Development section | — | — | +| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow | +| Skills / Skill Injection | "Why special" | — | — | +| Company Templates | "Why special", Roadmap (ClipMart) | — | — | +| Mobile / UI | Features table | — | — | +| Project Archiving | — | — | — | +| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution | diff --git a/.agents/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md new file mode 100644 index 00000000..5064b67c --- /dev/null +++ b/.agents/skills/pr-report/SKILL.md @@ -0,0 +1,202 @@ +--- +name: pr-report +description: > + Review a pull request or contribution deeply, explain it tutorial-style for a + maintainer, and produce a polished report artifact such as HTML or Markdown. + Use when asked to analyze a PR, explain a contributor's design decisions, + compare it with similar systems, or prepare a merge recommendation. +--- + +# PR Report Skill + +Produce a maintainer-grade review of a PR, branch, or large contribution. + +Default posture: + +- understand the change before judging it +- explain the system as built, not just the diff +- separate architectural problems from product-scope objections +- make a concrete recommendation, not a vague impression + +## When to Use + +Use this skill when the user asks for things like: + +- "review this PR deeply" +- "explain this contribution to me" +- "make me a report or webpage for this PR" +- "compare this design to similar systems" +- "should I merge this?" + +## Outputs + +Common outputs: + +- standalone HTML report in `tmp/reports/...` +- Markdown report in `report/` or another requested folder +- short maintainer summary in chat + +If the user asks for a webpage, build a polished standalone HTML artifact with +clear sections and readable visual hierarchy. + +Resources bundled with this skill: + +- `references/style-guide.md` for visual direction and report presentation rules +- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter + +## Workflow + +### 1. Acquire and frame the target + +Work from local code when possible, not just the GitHub PR page. + +Gather: + +- target branch or worktree +- diff size and changed subsystems +- relevant repo docs, specs, and invariants +- contributor intent if it is documented in PR text or design docs + +Start by answering: what is this change *trying* to become? + +### 2. Build a mental model of the system + +Do not stop at file-by-file notes. Reconstruct the design: + +- what new runtime or contract exists +- which layers changed: db, shared types, server, UI, CLI, docs +- lifecycle: install, startup, execution, UI, failure, disablement +- trust boundary: what code runs where, under what authority + +For large contributions, include a tutorial-style section that teaches the +system from first principles. + +### 3. Review like a maintainer + +Findings come first. Order by severity. + +Prioritize: + +- behavioral regressions +- trust or security gaps +- misleading abstractions +- lifecycle and operational risks +- coupling that will be hard to unwind +- missing tests or unverifiable claims + +Always cite concrete file references when possible. + +### 4. Distinguish the objection type + +Be explicit about whether a concern is: + +- product direction +- architecture +- implementation quality +- rollout strategy +- documentation honesty + +Do not hide an architectural objection inside a scope objection. + +### 5. Compare to external precedents when needed + +If the contribution introduces a framework or platform concept, compare it to +similar open-source systems. + +When comparing: + +- prefer official docs or source +- focus on extension boundaries, context passing, trust model, and UI ownership +- extract lessons, not just similarities + +Good comparison questions: + +- Who owns lifecycle? +- Who owns UI composition? +- Is context explicit or ambient? +- Are plugins trusted code or sandboxed code? +- Are extension points named and typed? + +### 6. Make the recommendation actionable + +Do not stop at "merge" or "do not merge." + +Choose one: + +- merge as-is +- merge after specific redesign +- salvage specific pieces +- keep as design research + +If rejecting or narrowing, say what should be kept. + +Useful recommendation buckets: + +- keep the protocol/type model +- redesign the UI boundary +- narrow the initial surface area +- defer third-party execution +- ship a host-owned extension-point model first + +### 7. Build the artifact + +Suggested report structure: + +1. Executive summary +2. What the PR actually adds +3. Tutorial: how the system works +4. Strengths +5. Main findings +6. Comparisons +7. Recommendation + +For HTML reports: + +- use intentional typography and color +- make navigation easy for long reports +- favor strong section headings and small reference labels +- avoid generic dashboard styling + +Before building from scratch, read `references/style-guide.md`. +If a fast polished starter is helpful, begin from `assets/html-report-starter.html` +and replace the placeholder content with the actual report. + +### 8. Verify before handoff + +Check: + +- artifact path exists +- findings still match the actual code +- any requested forbidden strings are absent from generated output +- if tests were not run, say so explicitly + +## Review Heuristics + +### Plugin and platform work + +Watch closely for: + +- docs claiming sandboxing while runtime executes trusted host processes +- module-global state used to smuggle React context +- hidden dependence on render order +- plugins reaching into host internals instead of using explicit APIs +- "capabilities" that are really policy labels on top of fully trusted code + +### Good signs + +- typed contracts shared across layers +- explicit extension points +- host-owned lifecycle +- honest trust model +- narrow first rollout with room to grow + +## Final Response + +In chat, summarize: + +- where the report is +- your overall call +- the top one or two reasons +- whether verification or tests were skipped + +Keep the chat summary shorter than the report itself. diff --git a/.agents/skills/pr-report/assets/html-report-starter.html b/.agents/skills/pr-report/assets/html-report-starter.html new file mode 100644 index 00000000..be6f0550 --- /dev/null +++ b/.agents/skills/pr-report/assets/html-report-starter.html @@ -0,0 +1,426 @@ + + + + + + PR Report Starter + + + + + + +
+ + +
+
+
Executive Summary
+

Use the hero for the clearest one-line judgment.

+

+ Replace this with the short explanation of what the contribution does, why it matters, + and what the core maintainer question is. +

+
+ Strength + Tradeoff + Risk +
+
+
+
Overall Call
+
Placeholder
+
+
+
Main Concern
+
Placeholder
+
+
+
Best Part
+
Placeholder
+
+
+
Weakest Part
+
Placeholder
+
+
+
+ Use this block for the thesis, a sharp takeaway, or a key cited point. +
+
+ +
+

Tutorial Section

+
+
+

Concept Card

+

Use cards for mental models, subsystems, or comparison slices.

+
path/to/file.ts:10
+
+
+

Second Card

+

Keep cards fairly dense. This template is about style, not fixed structure.

+
path/to/file.ts:20
+
+
+
+ +
+

Findings

+
+
High
+

Finding Title

+

Use findings for the sharpest judgment calls and risks.

+
path/to/file.ts:30
+
+
+ +
+

Recommendation

+
+
+

Path Forward

+

Use this area for merge guidance, salvage plan, or rollout advice.

+
+
+

What To Keep

+

Call out the parts worth preserving even if the whole proposal should not land.

+
+
+
+
+
+ + diff --git a/.agents/skills/pr-report/references/style-guide.md b/.agents/skills/pr-report/references/style-guide.md new file mode 100644 index 00000000..35158d1a --- /dev/null +++ b/.agents/skills/pr-report/references/style-guide.md @@ -0,0 +1,149 @@ +# PR Report Style Guide + +Use this guide when the user wants a report artifact, especially a webpage. + +## Goal + +Make the report feel like an editorial review, not an internal admin dashboard. +The page should make a long technical argument easy to scan without looking +generic or overdesigned. + +## Visual Direction + +Preferred tone: + +- editorial +- warm +- serious +- high-contrast +- handcrafted, not corporate SaaS + +Avoid: + +- default app-shell layouts +- purple gradients on white +- generic card dashboards +- cramped pages with weak hierarchy +- novelty fonts that hurt readability + +## Typography + +Recommended pattern: + +- one expressive serif or display face for major headings +- one sturdy sans-serif for body copy and UI labels + +Good combinations: + +- Newsreader + IBM Plex Sans +- Source Serif 4 + Instrument Sans +- Fraunces + Public Sans +- Libre Baskerville + Work Sans + +Rules: + +- headings should feel deliberate and large +- body copy should stay comfortable for long reading +- reference labels and badges should use smaller dense sans text + +## Layout + +Recommended structure: + +- a sticky side or top navigation for long reports +- one strong hero summary at the top +- panel or paper-like sections for each major topic +- multi-column card grids for comparisons and strengths +- single-column body text for findings and recommendations + +Use generous spacing. Long-form technical reports need breathing room. + +## Color + +Prefer muted paper-like backgrounds with one warm accent and one cool counterweight. + +Suggested token categories: + +- `--bg` +- `--paper` +- `--ink` +- `--muted` +- `--line` +- `--accent` +- `--good` +- `--warn` +- `--bad` + +The accent should highlight navigation, badges, and important labels. Do not +let accent colors dominate body text. + +## Useful UI Elements + +Include small reusable styles for: + +- summary metrics +- badges +- quotes or callouts +- finding cards +- severity labels +- reference labels +- comparison cards +- responsive two-column sections + +## Motion + +Keep motion restrained. + +Good: + +- soft fade/slide-in on first load +- hover response on nav items or cards + +Bad: + +- constant animation +- floating blobs +- decorative motion with no reading benefit + +## Content Presentation + +Even when the user wants design polish, clarity stays primary. + +Good structure for long reports: + +1. executive summary +2. what changed +3. tutorial explanation +4. strengths +5. findings +6. comparisons +7. recommendation + +The exact headings can change. The important thing is to separate explanation +from judgment. + +## References + +Reference labels should be visually quiet but easy to spot. + +Good pattern: + +- small muted text +- monospace or compact sans +- keep them close to the paragraph they support + +## Starter Usage + +If you need a fast polished base, start from: + +- `assets/html-report-starter.html` + +Customize: + +- fonts +- color tokens +- hero copy +- section ordering +- card density + +Do not preserve the placeholder sections if they do not fit the actual report. diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md new file mode 100644 index 00000000..4b1cdba0 --- /dev/null +++ b/.agents/skills/release-changelog/SKILL.md @@ -0,0 +1,178 @@ +--- +name: release-changelog +description: > + Generate the stable Paperclip release changelog at releases/v{version}.md by + reading commits, changesets, and merged PR context since the last stable tag. +--- + +# Release Changelog Skill + +Generate the user-facing changelog for the **stable** Paperclip release. + +Output: + +- `releases/v{version}.md` + +Important rule: + +- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md` + +## Step 0 — Idempotency Check + +Before generating anything, check whether the file already exists: + +```bash +ls releases/v{version}.md 2>/dev/null +``` + +If it exists: + +1. read it first +2. present it to the reviewer +3. ask whether to keep it, regenerate it, or update specific sections +4. never overwrite it silently + +## Step 1 — Determine the Stable Range + +Find the last stable tag: + +```bash +git tag --list 'v*' --sort=-version:refname | head -1 +git log v{last}..HEAD --oneline --no-merges +``` + +The planned stable version comes from one of: + +- an explicit maintainer request +- the chosen bump type applied to the last stable tag +- the release plan already agreed in `doc/RELEASING.md` + +Do not derive the changelog version from a canary tag or prerelease suffix. + +## Step 2 — Gather the Raw Inputs + +Collect release data from: + +1. git commits since the last stable tag +2. `.changeset/*.md` files +3. merged PRs via `gh` when available + +Useful commands: + +```bash +git log v{last}..HEAD --oneline --no-merges +git log v{last}..HEAD --format="%H %s" --no-merges +ls .changeset/*.md | grep -v README.md +gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels +``` + +## Step 3 — Detect Breaking Changes + +Look for: + +- destructive migrations +- removed or changed API fields/endpoints +- renamed or removed config keys +- `major` changesets +- `BREAKING:` or `BREAKING CHANGE:` commit signals + +Key commands: + +```bash +git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ +git diff v{last}..HEAD -- packages/db/src/schema/ +git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +If the requested bump is lower than the minimum required bump, flag that before the release proceeds. + +## Step 4 — Categorize for Users + +Use these stable changelog sections: + +- `Breaking Changes` +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed + +Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users. + +Guidelines: + +- group related commits into one user-facing entry +- write from the user perspective +- keep highlights short and concrete +- spell out upgrade actions for breaking changes + +### Inline PR and contributor attribution + +When a bullet item clearly maps to a merged pull request, add inline attribution at the +end of the entry in this format: + +``` +- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2) +``` + +Rules: + +- Only add a PR link when you can confidently trace the bullet to a specific merged PR. + Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs. +- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails. +- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`. +- If you cannot determine the PR number or contributor with confidence, omit the attribution + parenthetical — do not guess. +- Core maintainer commits that don't have an external PR can omit the parenthetical. + +## Step 5 — Write the File + +Template: + +```markdown +# v{version} + +> Released: {YYYY-MM-DD} + +## Breaking Changes + +## Highlights + +## Improvements + +## Fixes + +## Upgrade Guide + +## Contributors + +Thank you to everyone who contributed to this release! + +@username1, @username2, @username3 +``` + +Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. + +The `Contributors` section should always be included. List every person who authored +commits in the release range, @-mentioning them by their **GitHub username** (not their +real name or email). To find GitHub usernames: + +1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username. +2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`. +3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page. + +**Never expose contributor email addresses.** Use `@username` only. + +Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors +in alphabetical order by GitHub username (case-insensitive). + +## Step 6 — Review Before Release + +Before handing it off: + +1. confirm the heading is the stable version only +2. confirm there is no `-canary` language in the title or filename +3. confirm any breaking changes have an upgrade path +4. present the draft for human sign-off + +This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 00000000..2eac6ad8 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,261 @@ +--- +name: release +description: > + Coordinate a full Paperclip release across engineering verification, npm, + GitHub, website publishing, and announcement follow-up. Use when leadership + asks to ship a release, not merely to discuss version bumps. +--- + +# Release Coordination Skill + +Run the full Paperclip release as a maintainer workflow, not just an npm publish. + +This skill coordinates: + +- stable changelog drafting via `release-changelog` +- release-train setup via `scripts/release-start.sh` +- prerelease canary publishing via `scripts/release.sh --canary` +- Docker smoke testing via `scripts/docker-onboard-smoke.sh` +- stable publishing via `scripts/release.sh` +- pushing the stable branch commit and tag +- GitHub Release creation via `scripts/create-github-release.sh` +- website / announcement follow-up tasks + +## Trigger + +Use this skill when leadership asks for: + +- "do a release" +- "ship the next patch/minor/major" +- "release vX.Y.Z" + +## Preconditions + +Before proceeding, verify all of the following: + +1. `.agents/skills/release-changelog/SKILL.md` exists and is usable. +2. The repo working tree is clean, including untracked files. +3. There are commits since the last stable tag. +4. The release SHA has passed the verification gate or is about to. +5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut. +6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +7. If running through Paperclip, you have issue context for status updates and follow-up task creation. + +If any precondition fails, stop and report the blocker. + +## Inputs + +Collect these inputs up front: + +- requested bump: `patch`, `minor`, or `major` +- whether this run is a dry run or live release +- whether the release is being run locally or from GitHub Actions +- release issue / company context for website and announcement follow-up + +## Step 0 — Release Model + +Paperclip now uses this release model: + +1. Start or resume `release/X.Y.Z` +2. Draft the **stable** changelog as `releases/vX.Y.Z.md` +3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` +4. Smoke test the canary via Docker +5. Publish the stable version `X.Y.Z` +6. Push the stable branch commit and tag +7. Create the GitHub Release +8. Merge `release/X.Y.Z` back to `master` without squash or rebase +9. Complete website and announcement surfaces + +Critical consequence: + +- Canaries do **not** use promote-by-dist-tag anymore. +- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`. + +## Step 1 — Decide the Stable Version + +Start the release train first: + +```bash +./scripts/release-start.sh {patch|minor|major} +``` + +Then run release preflight: + +```bash +./scripts/release-preflight.sh canary {patch|minor|major} +# or +./scripts/release-preflight.sh stable {patch|minor|major} +``` + +Then use the last stable tag as the base: + +```bash +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ +git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Bump policy: + +- destructive migrations, removed APIs, breaking config changes -> `major` +- additive migrations or clearly user-visible features -> at least `minor` +- fixes only -> `patch` + +If the requested bump is too low, escalate it and explain why. + +## Step 2 — Draft the Stable Changelog + +Invoke `release-changelog` and generate: + +- `releases/vX.Y.Z.md` + +Rules: + +- review the draft with a human before publish +- preserve manual edits if the file already exists +- keep the heading and filename stable-only, for example `v1.2.3` +- do not create a separate canary changelog file + +## Step 3 — Verify the Release SHA + +Run the standard gate: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. + +The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping. + +## Step 4 — Publish a Canary + +Run from the `release/X.Y.Z` branch: + +```bash +./scripts/release.sh {patch|minor|major} --canary --dry-run +./scripts/release.sh {patch|minor|major} --canary +``` + +What this means: + +- npm receives `X.Y.Z-canary.N` under dist-tag `canary` +- `latest` remains unchanged +- no git tag is created +- the script cleans the working tree afterward + +Guard: + +- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable + +After publish, verify: + +```bash +npm view paperclipai@canary version +``` + +The user install path is: + +```bash +npx paperclipai@canary onboard +``` + +## Step 5 — Smoke Test the Canary + +Run: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Confirm: + +1. install succeeds +2. onboarding completes +3. server boots +4. UI loads +5. basic company/dashboard flow works + +If smoke testing fails: + +- stop the stable release +- fix the issue +- publish another canary +- repeat the smoke test + +Each retry should create a higher canary ordinal, while the stable target version can stay the same. + +## Step 6 — Publish Stable + +Once the SHA is vetted, run: + +```bash +./scripts/release.sh {patch|minor|major} --dry-run +./scripts/release.sh {patch|minor|major} +``` + +Stable publish does this: + +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local git tag `vX.Y.Z` + +Stable publish does **not** push the release for you. + +## Step 7 — Push and Create GitHub Release + +After stable publish succeeds: + +```bash +git push public-gh HEAD --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +Use the stable changelog file as the GitHub Release notes source. + +Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase. + +## Step 8 — Finish the Other Surfaces + +Create or verify follow-up work for: + +- website changelog publishing +- launch post / social announcement +- any release summary in Paperclip issue context + +These should reference the stable release, not the canary. + +## Failure Handling + +If the canary is bad: + +- publish another canary, do not ship stable + +If stable npm publish succeeds but push or GitHub release creation fails: + +- fix the git/GitHub issue immediately from the same checkout +- do not republish the same version + +If `latest` is bad after stable publish: + +```bash +./scripts/rollback-latest.sh +``` + +Then fix forward with a new patch release. + +## Output + +When the skill completes, provide: + +- stable version and, if relevant, the final canary version tested +- verification status +- npm status +- git tag / GitHub Release status +- website / announcement follow-up status +- rollback recommendation if anything is still partially complete diff --git a/.changeset/add-pi-adapter-support.md b/.changeset/add-pi-adapter-support.md deleted file mode 100644 index 97005a39..00000000 --- a/.changeset/add-pi-adapter-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@paperclipai/shared": minor ---- - -Add support for Pi local adapter in constants and onboarding UI. \ No newline at end of file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d154627 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 00000000..16953380 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,49 @@ +name: PR Policy + +on: + pull_request: + branches: + - master + +concurrency: + group: pr-policy-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + policy: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Block manual lockfile edits + if: github.head_ref != 'chore/refresh-lockfile' + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then + echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates." + exit 1 + fi + + - name: Validate dependency resolution when manifests change + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' + if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/pr-verify.yml similarity index 82% rename from .github/workflows/ci.yml rename to .github/workflows/pr-verify.yml index 4a44003c..e84e448a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/pr-verify.yml @@ -1,15 +1,12 @@ -name: CI +name: PR Verify on: pull_request: branches: - master - push: - branches: - - master concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: pr-verify-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: @@ -33,7 +30,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --no-frozen-lockfile - name: Typecheck run: pnpm -r typecheck diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml new file mode 100644 index 00000000..a879e5bc --- /dev/null +++ b/.github/workflows/refresh-lockfile.yml @@ -0,0 +1,81 @@ +name: Refresh Lockfile + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: refresh-lockfile-master + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Refresh pnpm lockfile + run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + + - name: Fail on unexpected file changes + run: | + changed="$(git status --porcelain)" + if [ -z "$changed" ]; then + echo "Lockfile is already up to date." + exit 0 + fi + if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then + echo "Unexpected files changed during lockfile refresh:" + echo "$changed" + exit 1 + fi + + - name: Create or update pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- pnpm-lock.yaml; then + echo "Lockfile unchanged, nothing to do." + exit 0 + fi + + BRANCH="chore/refresh-lockfile" + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push --force origin "$BRANCH" + + # Create PR if one doesn't already exist + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -z "$existing" ]; then + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + echo "Created new PR." + else + echo "PR #$existing already exists, branch updated via force push." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..7165d059 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + default: canary + options: + - canary + - stable + bump: + description: Semantic version bump + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Preview the release without publishing + required: true + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + if: startsWith(github.ref, 'refs/heads/release/') + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + publish: + if: startsWith(github.ref, 'refs/heads/release/') + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-release + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - 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: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run release script + env: + GITHUB_ACTIONS: "true" + run: | + args=("${{ inputs.bump }}") + if [ "${{ inputs.channel }}" = "canary" ]; then + args+=("--canary") + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=("--dry-run") + fi + ./scripts/release.sh "${args[@]}" + + - name: Push stable release branch commit and tag + if: inputs.channel == 'stable' && !inputs.dry_run + run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags + + - name: Create GitHub Release + if: inputs.channel == 'stable' && !inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Error: no v* tag points at HEAD after stable release." >&2 + exit 1 + fi + ./scripts/create-github-release.sh "$version" diff --git a/.gitignore b/.gitignore index 6b68f737..f2c9b9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,11 @@ tmp/ .paperclip-local/ /.idea/ /.agents/ + +# Doc maintenance cursor +.doc-review-cursor + +# Playwright +tests/e2e/test-results/ +tests/e2e/playwright-report/ +.superset/ diff --git a/AGENTS.md b/AGENTS.md index e4b5b514..dad6684f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,9 @@ If you change schema/API behavior, update all impacted layers: 4. Do not replace strategic docs wholesale unless asked. Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned. +5. Keep plan docs dated and centralized. +New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. + ## 6. Database Change Workflow When changing data model: diff --git a/Dockerfile b/Dockerfile index 0fcc3216..014113e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,11 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ -COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ +COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ +COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ + RUN pnpm install --frozen-lockfile FROM base AS build @@ -30,8 +33,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" & FROM base AS production WORKDIR /app -COPY --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest +COPY --chown=node:node --from=build /app /app +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ + && mkdir -p /paperclip \ + && chown node:node /paperclip ENV NODE_ENV=production \ HOME=/paperclip \ @@ -47,4 +52,5 @@ ENV NODE_ENV=production \ VOLUME ["/paperclip"] EXPOSE 3100 +USER node CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] diff --git a/README.md b/README.md index c3d9fc8e..391a0feb 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. - ⚪ ClipMart - buy and sell entire agent companies - ⚪ Easy agent configurations / easier to understand - ⚪ Better support for harness engineering -- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) +- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) - ⚪ Better docs
@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. - -
## Community diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e72da839..d261b8a8 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,44 @@ # paperclipai +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + - @paperclipai/adapter-claude-local@0.3.1 + - @paperclipai/adapter-codex-local@0.3.1 + - @paperclipai/adapter-cursor-local@0.3.1 + - @paperclipai/adapter-gemini-local@0.3.1 + - @paperclipai/adapter-openclaw-gateway@0.3.1 + - @paperclipai/adapter-opencode-local@0.3.1 + - @paperclipai/adapter-pi-local@0.3.1 + - @paperclipai/db@0.3.1 + - @paperclipai/shared@0.3.1 + - @paperclipai/server@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies [6077ae6] +- Updated dependencies + - @paperclipai/shared@0.3.0 + - @paperclipai/adapter-utils@0.3.0 + - @paperclipai/adapter-claude-local@0.3.0 + - @paperclipai/adapter-codex-local@0.3.0 + - @paperclipai/adapter-cursor-local@0.3.0 + - @paperclipai/adapter-openclaw-gateway@0.3.0 + - @paperclipai/adapter-opencode-local@0.3.0 + - @paperclipai/adapter-pi-local@0.3.0 + - @paperclipai/db@0.3.0 + - @paperclipai/server@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index c116047c..7976b7c9 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,7 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that should NOT be bundled — they'll be published diff --git a/cli/package.json b/cli/package.json index 84edcb58..4bda09ed 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "paperclipai", - "version": "0.2.7", + "version": "0.3.1", "description": "Paperclip CLI — orchestrate AI agent teams to run a business", "type": "module", "bin": { @@ -37,9 +37,10 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", @@ -47,6 +48,7 @@ "drizzle-orm": "0.38.4", "dotenv": "^17.0.1", "commander": "^13.1.0", + "embedded-postgres": "^18.1.0-beta.16", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 40bb1554..baf5db51 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ensureAgentJwtSecret, + mergePaperclipEnvEntries, readAgentJwtSecretFromEnv, + readPaperclipEnvEntries, resolveAgentJwtEnvFile, } from "../config/env.js"; import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js"; @@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => { const result = agentJwtSecretCheck(configPath); expect(result.status).toBe("pass"); }); + + it("quotes hash-prefixed env values so dotenv round-trips them", () => { + const configPath = tempConfigPath(); + const envPath = resolveAgentJwtEnvFile(configPath); + + mergePaperclipEnvEntries( + { + PAPERCLIP_WORKTREE_COLOR: "#439edb", + }, + envPath, + ); + + const contents = fs.readFileSync(envPath, "utf-8"); + expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"'); + expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb"); + }); }); diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42..572689c4 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: { provider: "local_disk", diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts new file mode 100644 index 00000000..83a67831 --- /dev/null +++ b/cli/src/__tests__/doctor.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { doctor } from "../commands/doctor.js"; +import { writeConfig } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTempConfig(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-")); + const configPath = path.join(root, ".paperclip", "config.json"); + const runtimeRoot = path.join(root, "runtime"); + + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-10T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 55432, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3199, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + writeConfig(config, configPath); + return configPath; +} + +describe("doctor", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("re-runs repairable checks so repaired failures do not remain blocking", async () => { + const configPath = createTempConfig(); + + const summary = await doctor({ + config: configPath, + repair: true, + yes: true, + }); + + expect(summary.failed).toBe(0); + expect(summary.warned).toBe(0); + expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy(); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts new file mode 100644 index 00000000..a8333ba5 --- /dev/null +++ b/cli/src/__tests__/worktree.test.ts @@ -0,0 +1,472 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + copyGitHooksToWorktreeGitDir, + copySeededSecretsKey, + rebindWorkspaceCwd, + resolveSourceConfigPath, + resolveGitWorktreeAddArgs, + resolveWorktreeMakeTargetPath, + worktreeInitCommand, + worktreeMakeCommand, +} from "../commands/worktree.js"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + formatShellExports, + generateWorktreeColor, + resolveWorktreeSeedPlan, + resolveWorktreeLocalPaths, + rewriteLocalUrlPort, + sanitizeWorktreeInstanceId, +} from "../commands/worktree-lib.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function buildSourceConfig(): PaperclipConfig { + return { + $meta: { + version: 1, + updatedAt: "2026-03-09T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/main/db", + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: "/tmp/main/backups", + }, + }, + logging: { + mode: "file", + logDir: "/tmp/main/logs", + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: ["localhost"], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit", + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/tmp/main/storage", + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: "/tmp/main/secrets/master.key", + }, + }, + }; +} + +describe("worktree helpers", () => { + it("sanitizes instance ids", () => { + expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); + expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); + }); + + it("resolves worktree:make target paths under the user home directory", () => { + expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe( + path.resolve(os.homedir(), "paperclip-pr-432"), + ); + }); + + it("rejects worktree:make names that are not safe directory/branch names", () => { + expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow( + "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", + ); + }); + + it("builds git worktree add args for new and existing branches", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: false, + }), + ).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]); + + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: true, + }), + ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); + }); + + it("builds git worktree add args with a start point", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: false, + startPoint: "public-gh/master", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); + }); + + it("uses start point even when a local branch with the same name exists", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: true, + startPoint: "origin/main", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); + }); + + it("rewrites loopback auth URLs to the new port only", () => { + expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); + expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); + }); + + it("builds isolated config and env paths for a worktree", () => { + const paths = resolveWorktreeLocalPaths({ + cwd: "/tmp/paperclip-feature", + homeDir: "/tmp/paperclip-worktrees", + instanceId: "feature-worktree-support", + }); + const config = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths, + serverPort: 3110, + databasePort: 54339, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + expect(config.database.embeddedPostgresDataDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), + ); + expect(config.database.embeddedPostgresPort).toBe(54339); + expect(config.server.port).toBe(3110); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); + expect(config.storage.localDisk.baseDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), + ); + + const env = buildWorktreeEnvEntries(paths, { + name: "feature-worktree-support", + color: "#3abf7a", + }); + expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); + expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); + }); + + it("generates vivid worktree colors as hex", () => { + expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); + }); + + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { + const minimal = resolveWorktreeSeedPlan("minimal"); + const full = resolveWorktreeSeedPlan("full"); + + expect(minimal.excludedTables).toContain("heartbeat_runs"); + expect(minimal.excludedTables).toContain("heartbeat_run_events"); + expect(minimal.excludedTables).toContain("workspace_runtime_services"); + expect(minimal.excludedTables).toContain("agent_task_sessions"); + expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); + + expect(full.excludedTables).toEqual([]); + expect(full.nullifyColumns).toEqual({}); + }); + + it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + try { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + const sourceConfig = buildSourceConfig(); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig, + sourceEnvEntries: {}, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); + } finally { + if (originalInlineMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey; + } + if (originalKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("writes the source inline secrets master key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig: buildSourceConfig(), + sourceEnvEntries: { + PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key", + }, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("persists the current agent jwt secret into the worktree env file", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret"; + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + const envPath = path.join(repoRoot, ".paperclip", ".env"); + const envContents = fs.readFileSync(envPath, "utf8"); + expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); + } finally { + process.chdir(originalCwd); + if (originalJwtSecret === undefined) { + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + } else { + process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("defaults the seed source config to the current repo-local Paperclip config", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); + const repoRoot = path.join(tempRoot, "repo"); + const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves the source config path across worktree:make cwd changes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "target"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(targetRoot); + + expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( + path.resolve(sourceConfigPath), + ); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rebinds same-repo workspace paths onto the current worktree root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/paperclip", + }), + ).toBe("/Users/example/paperclip-pr-432"); + + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/paperclip/packages/db", + }), + ).toBe("/Users/example/paperclip-pr-432/packages/db"); + }); + + it("does not rebind paths outside the source repo root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/other-project", + }), + ).toBeNull(); + }); + + it("copies shared git hooks into a linked worktree git dir", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-")); + const repoRoot = path.join(tempRoot, "repo"); + const worktreePath = path.join(tempRoot, "repo-feature"); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); + const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); + const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt"); + fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 }); + fs.chmodSync(sourceHookPath, 0o755); + fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8"); + + execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + + const copied = copyGitHooksToWorktreeGitDir(worktreePath); + const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: worktreePath, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir); + const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks")); + const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit"); + const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt"); + + expect(copied).toMatchObject({ + sourceHooksPath: resolvedSourceHooksDir, + targetHooksPath: resolvedTargetHooksDir, + copied: true, + }); + expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n"); + expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0); + expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n"); + } finally { + execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("creates and initializes a worktree from the top-level worktree:make command", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); + const repoRoot = path.join(tempRoot, "repo"); + const fakeHome = path.join(tempRoot, "home"); + const worktreePath = path.join(fakeHome, "paperclip-make-test"); + const originalCwd = process.cwd(); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(fakeHome, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + process.chdir(repoRoot); + + await worktreeMakeCommand("paperclip-make-test", { + seed: false, + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); + } finally { + process.chdir(originalCwd); + homedirSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 66829b2c..e4443f55 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -2,9 +2,10 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; +import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; -import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; +import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -33,13 +34,28 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, +const geminiLocalCLIAdapter: CLIAdapterModule = { + type: "gemini_local", + formatStdoutEvent: printGeminiStreamEvent, +}; + +const openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + piLocalCLIAdapter, + cursorLocalCLIAdapter, + geminiLocalCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 863249b7..60be8d2d 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -104,8 +104,10 @@ export class PaperclipApiClient { function buildUrl(apiBase: string, path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const [pathname, query] = normalizedPath.split("?"); const url = new URL(apiBase); - url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`; + url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; + if (query) url.search = query; return url.toString(); } diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts index 942c464b..d47a3bba 100644 --- a/cli/src/commands/allowed-hostname.ts +++ b/cli/src/commands/allowed-hostname.ts @@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string } p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`); } else { p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`); + p.log.message( + pc.dim("Restart the Paperclip server for this change to take effect."), + ); } if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) { diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index a844c447..dc720f63 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -3,6 +3,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { readConfig, resolveConfigPath } from "../config/store.js"; function hashToken(token: string) { @@ -13,7 +14,8 @@ function createInviteToken() { return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; } -function resolveDbUrl(configPath?: string) { +function resolveDbUrl(configPath?: string, explicitDbUrl?: string) { + if (explicitDbUrl) return explicitDbUrl; const config = readConfig(configPath); if (process.env.DATABASE_URL) return process.env.DATABASE_URL; if (config?.database.mode === "postgres" && config.database.connectionString) { @@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: { force?: boolean; expiresHours?: number; baseUrl?: string; + dbUrl?: string; }) { const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const config = readConfig(configPath); if (!config) { p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); @@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: { return; } - const dbUrl = resolveDbUrl(configPath); + const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { p.log.error( "Could not resolve database connection for bootstrap.", @@ -71,6 +75,11 @@ export async function bootstrapCeoInvite(opts: { } const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; try { const existingAdminCount = await db .select() @@ -118,5 +127,7 @@ export async function bootstrapCeoInvite(opts: { } catch (err) { p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`); p.log.info("If using embedded-postgres, start the Paperclip server and run this command again."); + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); } } diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2a1b4243..2c294628 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,13 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import { + removeMaintainerOnlySkillSymlinks, + resolvePaperclipSkillsDir, +} from "@paperclipai/adapter-utils/server-utils"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { addCommonClientOptions, formatInlineRecord, @@ -13,6 +21,141 @@ interface AgentListOptions extends BaseClientOptions { companyId?: string; } +interface AgentLocalCliOptions extends BaseClientOptions { + companyId?: string; + keyName?: string; + installSkills?: boolean; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface SkillsInstallSummary { + tool: "codex" | "claude"; + target: string; + linked: string[]; + removed: string[]; + skipped: string[]; + failed: Array<{ name: string; error: string }>; +} + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function codexSkillsHome(): string { + const fromEnv = process.env.CODEX_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); + return path.join(base, "skills"); +} + +function claudeSkillsHome(): string { + const fromEnv = process.env.CLAUDE_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); + return path.join(base, "skills"); +} + +async function installSkillsForTarget( + sourceSkillsDir: string, + targetSkillsDir: string, + tool: "codex" | "claude", +): Promise { + const summary: SkillsInstallSummary = { + tool, + target: targetSkillsDir, + linked: [], + removed: [], + skipped: [], + failed: [], + }; + + await fs.mkdir(targetSkillsDir, { recursive: true }); + const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + summary.removed = await removeMaintainerOnlySkillSymlinks( + targetSkillsDir, + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + ); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(sourceSkillsDir, entry.name); + const target = path.join(targetSkillsDir, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + if (existing.isSymbolicLink()) { + let linkedPath: string | null = null; + try { + linkedPath = await fs.readlink(target); + } catch (err) { + await fs.unlink(target); + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + continue; + } catch (linkErr) { + summary.failed.push({ + name: entry.name, + error: + err instanceof Error && linkErr instanceof Error + ? `${err.message}; then ${linkErr.message}` + : err instanceof Error + ? err.message + : `Failed to recover broken symlink: ${String(err)}`, + }); + continue; + } + } + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + const linkedTargetExists = await fs + .stat(resolvedLinkedPath) + .then(() => true) + .catch(() => false); + + if (!linkedTargetExists) { + await fs.unlink(target); + } else { + summary.skipped.push(entry.name); + continue; + } + } else { + summary.skipped.push(entry.name); + continue; + } + } + + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + } catch (err) { + summary.failed.push({ + name: entry.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return summary; +} + +function buildAgentEnvExports(input: { + apiBase: string; + companyId: string; + agentId: string; + apiKey: string; +}): string { + const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); + return [ + `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, + `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`, + `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`, + `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`, + ].join("\n"); +} + export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); @@ -71,4 +214,102 @@ export function registerAgentCommands(program: Command): void { } }), ); + + addCommonClientOptions( + agent + .command("local-cli") + .description( + "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", + ) + .argument("", "Agent ID or shortname/url-key") + .requiredOption("-C, --company-id ", "Company ID") + .option("--key-name ", "API key label", "local-cli") + .option( + "--no-install-skills", + "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", + ) + .action(async (agentRef: string, opts: AgentLocalCliOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); + const agentRow = await ctx.api.get( + `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, + ); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } + + const now = new Date().toISOString().replaceAll(":", "-"); + const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; + const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + if (!key) { + throw new Error("Failed to create API key"); + } + + const installSummaries: SkillsInstallSummary[] = []; + if (opts.installSkills !== false) { + const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]); + if (!skillsDir) { + throw new Error( + "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + ); + } + + installSummaries.push( + await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), + await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + ); + } + + const exportsText = buildAgentEnvExports({ + apiBase: ctx.api.apiBase, + companyId: agentRow.companyId, + agentId: agentRow.id, + apiKey: key.token, + }); + + if (ctx.json) { + printOutput( + { + agent: { + id: agentRow.id, + name: agentRow.name, + urlKey: agentRow.urlKey, + companyId: agentRow.companyId, + }, + key: { + id: key.id, + name: key.name, + createdAt: key.createdAt, + token: key.token, + }, + skills: installSummaries, + exports: exportsText, + }, + { json: true }, + ); + return; + } + + console.log(`Agent: ${agentRow.name} (${agentRow.id})`); + console.log(`API key created: ${key.name} (${key.id})`); + if (installSummaries.length > 0) { + for (const summary of installSummaries) { + console.log( + `${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + ); + for (const failed of summary.failed) { + console.log(` failed ${failed.name}: ${failed.error}`); + } + } + } + console.log(""); + console.log("# Run this in your shell before launching codex/claude:"); + console.log(exportsText); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); } diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts new file mode 100644 index 00000000..9031d696 --- /dev/null +++ b/cli/src/commands/client/plugin.ts @@ -0,0 +1,374 @@ +import path from "node:path"; +import { Command } from "commander"; +import pc from "picocolors"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +// --------------------------------------------------------------------------- +// Types mirroring server-side shapes +// --------------------------------------------------------------------------- + +interface PluginRecord { + id: string; + pluginKey: string; + packageName: string; + version: string; + status: string; + displayName?: string; + lastError?: string | null; + installedAt: string; + updatedAt: string; +} + + +// --------------------------------------------------------------------------- +// Option types +// --------------------------------------------------------------------------- + +interface PluginListOptions extends BaseClientOptions { + status?: string; +} + +interface PluginInstallOptions extends BaseClientOptions { + local?: boolean; + version?: string; +} + +interface PluginUninstallOptions extends BaseClientOptions { + force?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Resolve a local path argument to an absolute path so the server can find the + * plugin on disk regardless of where the user ran the CLI. + */ +function resolvePackageArg(packageArg: string, isLocal: boolean): string { + if (!isLocal) return packageArg; + // Already absolute + if (path.isAbsolute(packageArg)) return packageArg; + // Expand leading ~ to home directory + if (packageArg.startsWith("~")) { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, "")); + } + return path.resolve(process.cwd(), packageArg); +} + +function formatPlugin(p: PluginRecord): string { + const statusColor = + p.status === "ready" + ? pc.green(p.status) + : p.status === "error" + ? pc.red(p.status) + : p.status === "disabled" + ? pc.dim(p.status) + : pc.yellow(p.status); + + const parts = [ + `key=${pc.bold(p.pluginKey)}`, + `status=${statusColor}`, + `version=${p.version}`, + `id=${pc.dim(p.id)}`, + ]; + + if (p.lastError) { + parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`); + } + + return parts.join(" "); +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + +export function registerPluginCommands(program: Command): void { + const plugin = program.command("plugin").description("Plugin lifecycle management"); + + // ------------------------------------------------------------------------- + // plugin list + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("list") + .description("List installed plugins") + .option("--status ", "Filter by status (ready, error, disabled, installed, upgrade_pending)") + .action(async (opts: PluginListOptions) => { + try { + const ctx = resolveCommandContext(opts); + const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ""; + const plugins = await ctx.api.get(`/api/plugins${qs}`); + + if (ctx.json) { + printOutput(plugins, { json: true }); + return; + } + + const rows = plugins ?? []; + if (rows.length === 0) { + console.log(pc.dim("No plugins installed.")); + return; + } + + for (const p of rows) { + console.log(formatPlugin(p)); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin install + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("install ") + .description( + "Install a plugin from a local path or npm package.\n" + + " Examples:\n" + + " paperclipai plugin install ./my-plugin # local path\n" + + " paperclipai plugin install @acme/plugin-linear # npm package\n" + + " paperclipai plugin install @acme/plugin-linear@1.2 # pinned version", + ) + .option("-l, --local", "Treat as a local filesystem path", false) + .option("--version ", "Specific npm version to install (npm packages only)") + .action(async (packageArg: string, opts: PluginInstallOptions) => { + try { + const ctx = resolveCommandContext(opts); + + // Auto-detect local paths: starts with . or / or ~ or is an absolute path + const isLocal = + opts.local || + packageArg.startsWith("./") || + packageArg.startsWith("../") || + packageArg.startsWith("/") || + packageArg.startsWith("~"); + + const resolvedPackage = resolvePackageArg(packageArg, isLocal); + + if (!ctx.json) { + console.log( + pc.dim( + isLocal + ? `Installing plugin from local path: ${resolvedPackage}` + : `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`, + ), + ); + } + + const installedPlugin = await ctx.api.post("/api/plugins/install", { + packageName: resolvedPackage, + version: opts.version, + isLocalPath: isLocal, + }); + + if (ctx.json) { + printOutput(installedPlugin, { json: true }); + return; + } + + if (!installedPlugin) { + console.log(pc.dim("Install returned no plugin record.")); + return; + } + + console.log( + pc.green( + `✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`, + ), + ); + + if (installedPlugin.lastError) { + console.log(pc.red(` Warning: ${installedPlugin.lastError}`)); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin uninstall + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("uninstall ") + .description( + "Uninstall a plugin by its plugin key or database ID.\n" + + " Use --force to hard-purge all state and config.", + ) + .option("--force", "Purge all plugin state and config (hard delete)", false) + .action(async (pluginKey: string, opts: PluginUninstallOptions) => { + try { + const ctx = resolveCommandContext(opts); + const purge = opts.force === true; + const qs = purge ? "?purge=true" : ""; + + if (!ctx.json) { + console.log( + pc.dim( + purge + ? `Uninstalling and purging plugin: ${pluginKey}` + : `Uninstalling plugin: ${pluginKey}`, + ), + ); + } + + const result = await ctx.api.delete( + `/api/plugins/${encodeURIComponent(pluginKey)}${qs}`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin enable + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("enable ") + .description("Enable a disabled or errored plugin") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post( + `/api/plugins/${encodeURIComponent(pluginKey)}/enable`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin disable + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("disable ") + .description("Disable a running plugin without uninstalling it") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post( + `/api/plugins/${encodeURIComponent(pluginKey)}/disable`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin inspect + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("inspect ") + .description("Show full details for an installed plugin") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get( + `/api/plugins/${encodeURIComponent(pluginKey)}`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + if (!result) { + console.log(pc.red(`Plugin not found: ${pluginKey}`)); + process.exit(1); + } + + console.log(formatPlugin(result)); + if (result.lastError) { + console.log(`\n${pc.red("Last error:")}\n${result.lastError}`); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin examples + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("examples") + .description("List bundled example plugins available for local install") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const examples = await ctx.api.get< + Array<{ + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: string; + }> + >("/api/plugins/examples"); + + if (ctx.json) { + printOutput(examples, { json: true }); + return; + } + + const rows = examples ?? []; + if (rows.length === 0) { + console.log(pc.dim("No bundled examples available.")); + return; + } + + for (const ex of rows) { + console.log( + `${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` + + ` ${ex.description}\n` + + ` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`, + ); + } + } catch (err) { + handleCommandError(err); + } + }), + ); +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index d072fee9..969ead97 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index f6ec1f4f..3ace070e 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -14,6 +14,7 @@ import { storageCheck, type CheckResult, } from "../checks/index.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; const STATUS_ICON = { @@ -31,6 +32,7 @@ export async function doctor(opts: { p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const results: CheckResult[] = []; // 1. Config check (must pass before others) @@ -64,28 +66,40 @@ export async function doctor(opts: { printResult(deploymentAuthResult); // 3. Agent JWT check - const jwtResult = agentJwtSecretCheck(opts.config); - results.push(jwtResult); - printResult(jwtResult); - await maybeRepair(jwtResult, opts); + results.push( + await runRepairableCheck({ + run: () => agentJwtSecretCheck(opts.config), + configPath, + opts, + }), + ); // 4. Secrets adapter check - const secretsResult = secretsCheck(config, configPath); - results.push(secretsResult); - printResult(secretsResult); - await maybeRepair(secretsResult, opts); + results.push( + await runRepairableCheck({ + run: () => secretsCheck(config, configPath), + configPath, + opts, + }), + ); // 5. Storage check - const storageResult = storageCheck(config, configPath); - results.push(storageResult); - printResult(storageResult); - await maybeRepair(storageResult, opts); + results.push( + await runRepairableCheck({ + run: () => storageCheck(config, configPath), + configPath, + opts, + }), + ); // 6. Database check - const dbResult = await databaseCheck(config, configPath); - results.push(dbResult); - printResult(dbResult); - await maybeRepair(dbResult, opts); + results.push( + await runRepairableCheck({ + run: () => databaseCheck(config, configPath), + configPath, + opts, + }), + ); // 7. LLM check const llmResult = await llmCheck(config); @@ -93,10 +107,13 @@ export async function doctor(opts: { printResult(llmResult); // 8. Log directory check - const logResult = logCheck(config, configPath); - results.push(logResult); - printResult(logResult); - await maybeRepair(logResult, opts); + results.push( + await runRepairableCheck({ + run: () => logCheck(config, configPath), + configPath, + opts, + }), + ); // 9. Port check const portResult = await portCheck(config); @@ -118,9 +135,9 @@ function printResult(result: CheckResult): void { async function maybeRepair( result: CheckResult, opts: { repair?: boolean; yes?: boolean }, -): Promise { - if (result.status === "pass" || !result.canRepair || !result.repair) return; - if (!opts.repair) return; +): Promise { + if (result.status === "pass" || !result.canRepair || !result.repair) return false; + if (!opts.repair) return false; let shouldRepair = opts.yes; if (!shouldRepair) { @@ -128,7 +145,7 @@ async function maybeRepair( message: `Repair "${result.name}"?`, initialValue: true, }); - if (p.isCancel(answer)) return; + if (p.isCancel(answer)) return false; shouldRepair = answer; } @@ -136,10 +153,30 @@ async function maybeRepair( try { await result.repair(); p.log.success(`Repaired: ${result.name}`); + return true; } catch (err) { p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`); } } + return false; +} + +async function runRepairableCheck(input: { + run: () => CheckResult | Promise; + configPath: string; + opts: { repair?: boolean; yes?: boolean }; +}): Promise { + let result = await input.run(); + printResult(result); + + const repaired = await maybeRepair(result, input.opts); + if (!repaired) return result; + + // Repairs may create/update the adjacent .env file or other local resources. + loadPaperclipEnvFile(input.configPath); + result = await input.run(); + printResult(result); + return result; } function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } { diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0e70d9cf..523484f3 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): { }, auth: { baseUrlMode: authBaseUrlMode, + disableSignUp: false, ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), }, storage: { @@ -228,6 +229,10 @@ function quickstartDefaultsFromEnv(): { return { defaults, usedEnvKeys, ignoredEnvKeys }; } +function canCreateBootstrapInviteImmediately(config: Pick): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres"; +} + export async function onboard(opts: OnboardOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); @@ -449,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise { "Next commands", ); - if (server.deploymentMode === "authenticated") { + if (canCreateBootstrapInviteImmediately({ database, server })) { p.log.step("Generating bootstrap CEO invite"); await bootstrapCeoInvite({ config: configPath }); } @@ -472,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise { return; } + if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") { + p.log.info( + [ + "Bootstrap CEO invite will be created after the server starts.", + `Next: ${pc.cyan("paperclipai run")}`, + `Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`, + ].join("\n"), + ); + } + p.outro("You're all set!"); } diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 6e061b2e..04743b48 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -3,9 +3,13 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { onboard } from "./onboard.js"; import { doctor } from "./doctor.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { configExists, resolveConfigPath } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { readConfig } from "../config/store.js"; import { describeLocalInstancePaths, resolvePaperclipHomeDir, @@ -19,6 +23,13 @@ interface RunOptions { yes?: boolean; } +interface StartedServer { + apiUrl: string; + databaseUrl: string; + host: string; + listenPort: number; +} + export async function runCommand(opts: RunOptions): Promise { const instanceId = resolvePaperclipInstanceId(opts.instance); process.env.PAPERCLIP_INSTANCE_ID = instanceId; @@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise { const configPath = resolveConfigPath(opts.config); process.env.PAPERCLIP_CONFIG = configPath; + loadPaperclipEnvFile(configPath); p.intro(pc.bgCyan(pc.black(" paperclipai run "))); p.log.message(pc.dim(`Home: ${paths.homeDir}`)); @@ -60,8 +72,41 @@ export async function runCommand(opts: RunOptions): Promise { process.exit(1); } + const config = readConfig(configPath); + if (!config) { + p.log.error(`No config found at ${configPath}.`); + process.exit(1); + } + p.log.step("Starting Paperclip server..."); - await importServerEntry(); + const startedServer = await importServerEntry(); + + if (shouldGenerateBootstrapInviteAfterStart(config)) { + p.log.step("Generating bootstrap CEO invite"); + await bootstrapCeoInvite({ + config: configPath, + dbUrl: startedServer.databaseUrl, + baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer), + }); + } +} + +function resolveBootstrapInviteBaseUrl( + config: PaperclipConfig, + startedServer: StartedServer, +): string { + const explicitBaseUrl = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + (config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined); + + if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) { + return explicitBaseUrl.trim().replace(/\/+$/, ""); + } + + return startedServer.apiUrl.replace(/\/api$/, ""); } function formatError(err: unknown): string { @@ -101,19 +146,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } -async function importServerEntry(): Promise { +async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { maybeEnableUiDevMiddleware(devEntry); - await import(pathToFileURL(devEntry).href); - return; + const mod = await import(pathToFileURL(devEntry).href); + return await startServerFromModule(mod, devEntry); } // Production mode: import the published @paperclipai/server package try { - await import("@paperclipai/server"); + const mod = await import("@paperclipai/server"); + return await startServerFromModule(mod, "@paperclipai/server"); } catch (err) { const missingSpecifier = getMissingModuleSpecifier(err); const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; @@ -130,3 +176,15 @@ async function importServerEntry(): Promise { ); } } + +function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres"; +} + +async function startServerFromModule(mod: unknown, label: string): Promise { + const startServer = (mod as { startServer?: () => Promise }).startServer; + if (typeof startServer !== "function") { + throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`); + } + return await startServer(); +} diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts new file mode 100644 index 00000000..5249acc2 --- /dev/null +++ b/cli/src/commands/worktree-lib.ts @@ -0,0 +1,274 @@ +import { randomInt } from "node:crypto"; +import path from "node:path"; +import type { PaperclipConfig } from "../config/schema.js"; +import { expandHomePrefix } from "../config/home.js"; + +export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; +export const WORKTREE_SEED_MODES = ["minimal", "full"] as const; + +export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; + +export type WorktreeSeedPlan = { + mode: WorktreeSeedMode; + excludedTables: string[]; + nullifyColumns: Record; +}; + +const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ + "activity_log", + "agent_runtime_state", + "agent_task_sessions", + "agent_wakeup_requests", + "cost_events", + "heartbeat_run_events", + "heartbeat_runs", + "workspace_runtime_services", +]; + +const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record = { + issues: ["checkout_run_id", "execution_run_id"], +}; + +export type WorktreeLocalPaths = { + cwd: string; + repoConfigDir: string; + configPath: string; + envPath: string; + homeDir: string; + instanceId: string; + instanceRoot: string; + contextPath: string; + embeddedPostgresDataDir: string; + backupDir: string; + logDir: string; + secretsKeyFilePath: string; + storageDir: string; +}; + +export type WorktreeUiBranding = { + name: string; + color: string; +}; + +export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { + return (WORKTREE_SEED_MODES as readonly string[]).includes(value); +} + +export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan { + if (mode === "full") { + return { + mode, + excludedTables: [], + nullifyColumns: {}, + }; + } + return { + mode, + excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES], + nullifyColumns: { + ...MINIMAL_WORKTREE_NULLIFIED_COLUMNS, + }, + }; +} + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +export function sanitizeWorktreeInstanceId(rawValue: string): string { + const trimmed = rawValue.trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string { + return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); +} + +function hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +export function generateWorktreeColor(): string { + return hslToHex(randomInt(0, 360), 68, 56); +} + +export function resolveWorktreeLocalPaths(opts: { + cwd: string; + homeDir?: string; + instanceId: string; +}): WorktreeLocalPaths { + const cwd = path.resolve(opts.cwd); + const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME)); + const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId); + const repoConfigDir = path.resolve(cwd, ".paperclip"); + return { + cwd, + repoConfigDir, + configPath: path.resolve(repoConfigDir, "config.json"), + envPath: path.resolve(repoConfigDir, ".env"), + homeDir, + instanceId: opts.instanceId, + instanceRoot, + contextPath: path.resolve(homeDir, "context.json"), + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + backupDir: path.resolve(instanceRoot, "data", "backups"), + logDir: path.resolve(instanceRoot, "logs"), + secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + storageDir: path.resolve(instanceRoot, "data", "storage"), + }; +} + +export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +export function buildWorktreeConfig(input: { + sourceConfig: PaperclipConfig | null; + paths: WorktreeLocalPaths; + serverPort: number; + databasePort: number; + now?: Date; +}): PaperclipConfig { + const { sourceConfig, paths, serverPort, databasePort } = input; + const nowIso = (input.now ?? new Date()).toISOString(); + + const source = sourceConfig; + const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort); + + return { + $meta: { + version: 1, + updatedAt: nowIso, + source: "configure", + }, + ...(source?.llm ? { llm: source.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: paths.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort, + backup: { + enabled: source?.database.backup.enabled ?? true, + intervalMinutes: source?.database.backup.intervalMinutes ?? 60, + retentionDays: source?.database.backup.retentionDays ?? 30, + dir: paths.backupDir, + }, + }, + logging: { + mode: source?.logging.mode ?? "file", + logDir: paths.logDir, + }, + server: { + deploymentMode: source?.server.deploymentMode ?? "local_trusted", + exposure: source?.server.exposure ?? "private", + host: source?.server.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: source?.server.allowedHostnames ?? [], + serveUi: source?.server.serveUi ?? true, + }, + auth: { + baseUrlMode: source?.auth.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: source?.auth.disableSignUp ?? false, + }, + storage: { + provider: source?.storage.provider ?? "local_disk", + localDisk: { + baseDir: paths.storageDir, + }, + s3: { + bucket: source?.storage.s3.bucket ?? "paperclip", + region: source?.storage.s3.region ?? "us-east-1", + endpoint: source?.storage.s3.endpoint, + prefix: source?.storage.s3.prefix ?? "", + forcePathStyle: source?.storage.s3.forcePathStyle ?? false, + }, + }, + secrets: { + provider: source?.secrets.provider ?? "local_encrypted", + strictMode: source?.secrets.strictMode ?? false, + localEncrypted: { + keyFilePath: paths.secretsKeyFilePath, + }, + }, + }; +} + +export function buildWorktreeEnvEntries( + paths: WorktreeLocalPaths, + branding?: WorktreeUiBranding, +): Record { + return { + PAPERCLIP_HOME: paths.homeDir, + PAPERCLIP_INSTANCE_ID: paths.instanceId, + PAPERCLIP_CONFIG: paths.configPath, + PAPERCLIP_CONTEXT: paths.contextPath, + PAPERCLIP_IN_WORKTREE: "true", + ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), + ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), + }; +} + +function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function formatShellExports(entries: Record): string { + return Object.entries(entries) + .filter(([, value]) => typeof value === "string" && value.trim().length > 0) + .map(([key, value]) => `export ${key}=${shellEscape(value)}`) + .join("\n"); +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts new file mode 100644 index 00000000..b77317fd --- /dev/null +++ b/cli/src/commands/worktree.ts @@ -0,0 +1,1125 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createServer } from "node:net"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { eq } from "drizzle-orm"; +import { + applyPendingMigrations, + createDb, + ensurePostgresDatabase, + formatDatabaseBackupResult, + projectWorkspaces, + runDatabaseBackup, + runDatabaseRestore, +} from "@paperclipai/db"; +import type { Command } from "commander"; +import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; +import { expandHomePrefix } from "../config/home.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; +import { printPaperclipCliBanner } from "../utils/banner.js"; +import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + DEFAULT_WORKTREE_HOME, + formatShellExports, + generateWorktreeColor, + isWorktreeSeedMode, + resolveSuggestedWorktreeName, + resolveWorktreeSeedPlan, + resolveWorktreeLocalPaths, + sanitizeWorktreeInstanceId, + type WorktreeSeedMode, + type WorktreeLocalPaths, +} from "./worktree-lib.js"; + +type WorktreeInitOptions = { + name?: string; + instance?: string; + home?: string; + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + sourceConfigPathOverride?: string; + serverPort?: number; + dbPort?: number; + seed?: boolean; + seedMode?: string; + force?: boolean; +}; + +type WorktreeMakeOptions = WorktreeInitOptions & { + startPoint?: string; +}; + +type WorktreeEnvOptions = { + config?: string; + json?: boolean; +}; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +type EmbeddedPostgresHandle = { + port: number; + startedByThisProcess: boolean; + stop: () => Promise; +}; + +type GitWorkspaceInfo = { + root: string; + commonDir: string; + gitDir: string; + hooksPath: string; +}; + +type CopiedGitHooksResult = { + sourceHooksPath: string; + targetHooksPath: string; + copied: boolean; +}; + +type SeedWorktreeDatabaseResult = { + backupSummary: string; + reboundWorkspaces: Array<{ + name: string; + fromCwd: string; + toCwd: string; + }>; +}; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { + const currentConfigPath = process.env.PAPERCLIP_CONFIG; + if (!currentConfigPath || currentConfigPath.trim().length === 0) { + return false; + } + return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); +} + +const WORKTREE_NAME_PREFIX = "paperclip-"; + +function resolveWorktreeMakeName(name: string): string { + const value = nonEmpty(name); + if (!value) { + throw new Error("Worktree name is required."); + } + if (!/^[A-Za-z0-9._-]+$/.test(value)) { + throw new Error( + "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", + ); + } + return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`; +} + +function resolveWorktreeHome(explicit?: string): string { + return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME; +} + +function resolveWorktreeStartPoint(explicit?: string): string | undefined { + return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined; +} + +export function resolveWorktreeMakeTargetPath(name: string): string { + return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); +} + +function extractExecSyncErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return error instanceof Error ? error.message : null; + } + + const stderr = "stderr" in error ? error.stderr : null; + if (typeof stderr === "string") { + return nonEmpty(stderr); + } + if (stderr instanceof Buffer) { + return nonEmpty(stderr.toString("utf8")); + } + + return error instanceof Error ? nonEmpty(error.message) : null; +} + +function localBranchExists(cwd: string, branchName: string): boolean { + try { + execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +export function resolveGitWorktreeAddArgs(input: { + branchName: string; + targetPath: string; + branchExists: boolean; + startPoint?: string; +}): string[] { + if (input.branchExists && !input.startPoint) { + return ["worktree", "add", input.targetPath, input.branchName]; + } + const commitish = input.startPoint ?? "HEAD"; + return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; +} + +function readPidFilePort(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const lines = readFileSync(postmasterPidFile, "utf8").split("\n"); + const port = Number(lines[3]?.trim()); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +function readRunningPostmasterPid(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + if (!Number.isInteger(pid) || pid <= 0) return null; + process.kill(pid, 0); + return pid; + } catch { + return null; + } +} + +async function isPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { + let port = Math.max(1, Math.trunc(preferredPort)); + while (reserved.has(port) || !(await isPortAvailable(port))) { + port += 1; + } + return port; +} + +function detectGitBranchName(cwd: string): string | null { + try { + const value = execFileSync("git", ["branch", "--show-current"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return nonEmpty(value); + } catch { + return null; + } +} + +function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { + try { + const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return { + root: path.resolve(root), + commonDir: path.resolve(root, commonDirRaw), + gitDir: path.resolve(root, gitDirRaw), + hooksPath: path.resolve(root, hooksPathRaw), + }; + } catch { + return null; + } +} + +function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { + if (!existsSync(sourceDir)) return false; + + const entries = readdirSync(sourceDir, { withFileTypes: true }); + if (entries.length === 0) return false; + + mkdirSync(targetDir, { recursive: true }); + + let copied = false; + for (const entry of entries) { + const sourcePath = path.resolve(sourceDir, entry.name); + const targetPath = path.resolve(targetDir, entry.name); + + if (entry.isDirectory()) { + mkdirSync(targetPath, { recursive: true }); + copyDirectoryContents(sourcePath, targetPath); + copied = true; + continue; + } + + if (entry.isSymbolicLink()) { + rmSync(targetPath, { recursive: true, force: true }); + symlinkSync(readlinkSync(sourcePath), targetPath); + copied = true; + continue; + } + + copyFileSync(sourcePath, targetPath); + try { + chmodSync(targetPath, statSync(sourcePath).mode & 0o777); + } catch { + // best effort + } + copied = true; + } + + return copied; +} + +export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null { + const workspace = detectGitWorkspaceInfo(cwd); + if (!workspace) return null; + + const sourceHooksPath = workspace.hooksPath; + const targetHooksPath = path.resolve(workspace.gitDir, "hooks"); + + if (sourceHooksPath === targetHooksPath) { + return { + sourceHooksPath, + targetHooksPath, + copied: false, + }; + } + + return { + sourceHooksPath, + targetHooksPath, + copied: copyDirectoryContents(sourceHooksPath, targetHooksPath), + }; +} + +export function rebindWorkspaceCwd(input: { + sourceRepoRoot: string; + targetRepoRoot: string; + workspaceCwd: string; +}): string | null { + const sourceRepoRoot = path.resolve(input.sourceRepoRoot); + const targetRepoRoot = path.resolve(input.targetRepoRoot); + const workspaceCwd = path.resolve(input.workspaceCwd); + const relative = path.relative(sourceRepoRoot, workspaceCwd); + if (!relative || relative === "") { + return targetRepoRoot; + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + return path.resolve(targetRepoRoot, relative); +} + +async function rebindSeededProjectWorkspaces(input: { + targetConnectionString: string; + currentCwd: string; +}): Promise { + const targetRepo = detectGitWorkspaceInfo(input.currentCwd); + if (!targetRepo) return []; + + const db = createDb(input.targetConnectionString); + const closableDb = db as typeof db & { + $client?: { end?: (opts?: { timeout?: number }) => Promise }; + }; + + try { + const rows = await db + .select({ + id: projectWorkspaces.id, + name: projectWorkspaces.name, + cwd: projectWorkspaces.cwd, + }) + .from(projectWorkspaces); + + const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; + for (const row of rows) { + const workspaceCwd = nonEmpty(row.cwd); + if (!workspaceCwd) continue; + + const sourceRepo = detectGitWorkspaceInfo(workspaceCwd); + if (!sourceRepo) continue; + if (sourceRepo.commonDir !== targetRepo.commonDir) continue; + + const reboundCwd = rebindWorkspaceCwd({ + sourceRepoRoot: sourceRepo.root, + targetRepoRoot: targetRepo.root, + workspaceCwd, + }); + if (!reboundCwd) continue; + + const normalizedCurrent = path.resolve(workspaceCwd); + if (reboundCwd === normalizedCurrent) continue; + if (!existsSync(reboundCwd)) continue; + + await db + .update(projectWorkspaces) + .set({ + cwd: reboundCwd, + updatedAt: new Date(), + }) + .where(eq(projectWorkspaces.id, row.id)); + + rebound.push({ + name: row.name, + fromCwd: normalizedCurrent, + toCwd: reboundCwd, + }); + } + + return rebound; + } finally { + await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + +export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { + if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); + if (opts.fromConfig) return path.resolve(opts.fromConfig); + if (!opts.fromDataDir && !opts.fromInstance) { + return resolveConfigPath(); + } + const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); + const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); + return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); +} + +function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record, portOverride?: number): string { + if (config.database.mode === "postgres") { + const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); + if (!connectionString) { + throw new Error( + "Source instance uses postgres mode but has no connection string in config or adjacent .env.", + ); + } + return connectionString; + } + + const port = portOverride ?? config.database.embeddedPostgresPort; + return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; +} + +export function copySeededSecretsKey(input: { + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + sourceEnvEntries: Record; + targetKeyFilePath: string; +}): void { + if (input.sourceConfig.secrets.provider !== "local_encrypted") { + return; + } + + mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); + + const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); + const sourceInlineMasterKey = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null); + if (sourceInlineMasterKey) { + writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } + return; + } + + const sourceKeyFileOverride = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null); + const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; + const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); + + if (!existsSync(sourceKeyFilePath)) { + throw new Error( + `Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`, + ); + } + + copyFileSync(sourceKeyFilePath, input.targetKeyFilePath); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } +} + +async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", + ); + } + + const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); + const runningPid = readRunningPostmasterPid(postmasterPidFile); + if (runningPid) { + return { + port: readPidFilePort(postmasterPidFile) ?? preferredPort, + startedByThisProcess: false, + stop: async () => {}, + }; + } + + const port = await findAvailablePort(preferredPort); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + + if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { + await instance.initialise(); + } + if (existsSync(postmasterPidFile)) { + rmSync(postmasterPidFile, { force: true }); + } + await instance.start(); + + return { + port, + startedByThisProcess: true, + stop: async () => { + await instance.stop(); + }, + }; +} + +async function seedWorktreeDatabase(input: { + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + targetConfig: PaperclipConfig; + targetPaths: WorktreeLocalPaths; + instanceId: string; + seedMode: WorktreeSeedMode; +}): Promise { + const seedPlan = resolveWorktreeSeedPlan(input.seedMode); + const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); + const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); + copySeededSecretsKey({ + sourceConfigPath: input.sourceConfigPath, + sourceConfig: input.sourceConfig, + sourceEnvEntries, + targetKeyFilePath: input.targetPaths.secretsKeyFilePath, + }); + let sourceHandle: EmbeddedPostgresHandle | null = null; + let targetHandle: EmbeddedPostgresHandle | null = null; + + try { + if (input.sourceConfig.database.mode === "embedded-postgres") { + sourceHandle = await ensureEmbeddedPostgres( + input.sourceConfig.database.embeddedPostgresDataDir, + input.sourceConfig.database.embeddedPostgresPort, + ); + } + const sourceConnectionString = resolveSourceConnectionString( + input.sourceConfig, + sourceEnvEntries, + sourceHandle?.port, + ); + const backup = await runDatabaseBackup({ + connectionString: sourceConnectionString, + backupDir: path.resolve(input.targetPaths.backupDir, "seed"), + retentionDays: 7, + filenamePrefix: `${input.instanceId}-seed`, + includeMigrationJournal: true, + excludeTables: seedPlan.excludedTables, + nullifyColumns: seedPlan.nullifyColumns, + }); + + targetHandle = await ensureEmbeddedPostgres( + input.targetConfig.database.embeddedPostgresDataDir, + input.targetConfig.database.embeddedPostgresPort, + ); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`; + await runDatabaseRestore({ + connectionString: targetConnectionString, + backupFile: backup.backupFile, + }); + await applyPendingMigrations(targetConnectionString); + const reboundWorkspaces = await rebindSeededProjectWorkspaces({ + targetConnectionString, + currentCwd: input.targetPaths.cwd, + }); + + return { + backupSummary: formatDatabaseBackupResult(backup), + reboundWorkspaces, + }; + } finally { + if (targetHandle?.startedByThisProcess) { + await targetHandle.stop(); + } + if (sourceHandle?.startedByThisProcess) { + await sourceHandle.stop(); + } + } +} + +async function runWorktreeInit(opts: WorktreeInitOptions): Promise { + const cwd = process.cwd(); + const worktreeName = resolveSuggestedWorktreeName( + cwd, + opts.name ?? detectGitBranchName(cwd) ?? undefined, + ); + const seedMode = opts.seedMode ?? "minimal"; + if (!isWorktreeSeedMode(seedMode)) { + throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + } + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); + const paths = resolveWorktreeLocalPaths({ + cwd, + homeDir: resolveWorktreeHome(opts.home), + instanceId, + }); + const branding = { + name: worktreeName, + color: generateWorktreeColor(), + }; + const sourceConfigPath = resolveSourceConfigPath(opts); + const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; + + if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) { + throw new Error( + `Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`, + ); + } + + if (opts.force) { + rmSync(paths.repoConfigDir, { recursive: true, force: true }); + rmSync(paths.instanceRoot, { recursive: true, force: true }); + } + + const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); + const serverPort = await findAvailablePort(preferredServerPort); + const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); + const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths, + serverPort, + databasePort, + }); + + writeConfig(targetConfig, paths.configPath); + const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); + const existingAgentJwtSecret = + nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? + nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); + mergePaperclipEnvEntries( + { + ...buildWorktreeEnvEntries(paths, branding), + ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), + }, + paths.envPath, + ); + ensureAgentJwtSecret(paths.configPath); + loadPaperclipEnvFile(paths.configPath); + const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); + + let seedSummary: string | null = null; + let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; + if (opts.seed !== false) { + if (!sourceConfig) { + throw new Error( + `Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`, + ); + } + const spinner = p.spinner(); + spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); + try { + const seeded = await seedWorktreeDatabase({ + sourceConfigPath, + sourceConfig, + targetConfig, + targetPaths: paths, + instanceId, + seedMode, + }); + seedSummary = seeded.backupSummary; + reboundWorkspaceSummary = seeded.reboundWorkspaces; + spinner.stop(`Seeded isolated worktree database (${seedMode}).`); + } catch (error) { + spinner.stop(pc.red("Failed to seed worktree database.")); + throw error; + } + } + + p.log.message(pc.dim(`Repo config: ${paths.configPath}`)); + p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); + p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); + p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); + p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); + if (copiedGitHooks?.copied) { + p.log.message( + pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`), + ); + } + if (seedSummary) { + p.log.message(pc.dim(`Seed mode: ${seedMode}`)); + p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); + for (const rebound of reboundWorkspaceSummary) { + p.log.message( + pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), + ); + } + } + p.outro( + pc.green( + `Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`, + ), + ); +} + +export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); + await runWorktreeInit(opts); +} + +export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); + + const name = resolveWorktreeMakeName(nameArg); + const startPoint = resolveWorktreeStartPoint(opts.startPoint); + const sourceCwd = process.cwd(); + const sourceConfigPath = resolveSourceConfigPath(opts); + const targetPath = resolveWorktreeMakeTargetPath(name); + if (existsSync(targetPath)) { + throw new Error(`Target path already exists: ${targetPath}`); + } + + mkdirSync(path.dirname(targetPath), { recursive: true }); + if (startPoint) { + const [remote] = startPoint.split("/", 1); + try { + execFileSync("git", ["fetch", remote], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error( + `Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, + ); + } + } + + const worktreeArgs = resolveGitWorktreeAddArgs({ + branchName: name, + targetPath, + branchExists: !startPoint && localBranchExists(sourceCwd, name), + startPoint, + }); + + const spinner = p.spinner(); + spinner.start(`Creating git worktree at ${targetPath}...`); + try { + execFileSync("git", worktreeArgs, { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Created git worktree at ${targetPath}.`); + } catch (error) { + spinner.stop(pc.red("Failed to create git worktree.")); + throw new Error(extractExecSyncErrorMessage(error) ?? String(error)); + } + + const installSpinner = p.spinner(); + installSpinner.start("Installing dependencies..."); + try { + execFileSync("pnpm", ["install"], { + cwd: targetPath, + stdio: ["ignore", "pipe", "pipe"], + }); + installSpinner.stop("Installed dependencies."); + } catch (error) { + installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway).")); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + + const originalCwd = process.cwd(); + try { + process.chdir(targetPath); + await runWorktreeInit({ + ...opts, + name, + sourceConfigPathOverride: sourceConfigPath, + }); + } catch (error) { + throw error; + } finally { + process.chdir(originalCwd); + } +} + +type WorktreeCleanupOptions = { + instance?: string; + home?: string; + force?: boolean; +}; + +type GitWorktreeListEntry = { + worktree: string; + branch: string | null; + bare: boolean; + detached: boolean; +}; + +function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { + const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const entries: GitWorktreeListEntry[] = []; + let current: Partial = {}; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + current = { worktree: line.slice("worktree ".length) }; + } else if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length); + } else if (line === "bare") { + current.bare = true; + } else if (line === "detached") { + current.detached = true; + } else if (line === "" && current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + bare: current.bare ?? false, + detached: current.detached ?? false, + }); + current = {}; + } + } + if (current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + bare: current.bare ?? false, + detached: current.detached ?? false, + }); + } + return entries; +} + +function branchHasUniqueCommits(cwd: string, branchName: string): boolean { + try { + const output = execFileSync( + "git", + ["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean { + try { + const output = execFileSync( + "git", + ["branch", "-r", "--list", `*/${branchName}`], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +function worktreePathHasUncommittedChanges(worktreePath: string): boolean { + try { + const output = execFileSync( + "git", + ["status", "--porcelain"], + { cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup "))); + + const name = resolveWorktreeMakeName(nameArg); + const sourceCwd = process.cwd(); + const targetPath = resolveWorktreeMakeTargetPath(name); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home))); + const instanceRoot = path.resolve(homeDir, "instances", instanceId); + + // ── 1. Assess current state ────────────────────────────────────────── + + const hasBranch = localBranchExists(sourceCwd, name); + const hasTargetDir = existsSync(targetPath); + const hasInstanceData = existsSync(instanceRoot); + + const worktrees = parseGitWorktreeList(sourceCwd); + const linkedWorktree = worktrees.find( + (wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath), + ); + + if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) { + p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found."); + p.outro(pc.green("Already clean.")); + return; + } + + // ── 2. Safety checks ──────────────────────────────────────────────── + + const problems: string[] = []; + + if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) { + const onRemote = branchExistsOnAnyRemote(sourceCwd, name); + if (onRemote) { + p.log.info( + `Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`, + ); + } else { + problems.push( + `Branch "${name}" has commits not found on any other branch or remote. ` + + `Deleting it will lose work. Push it first, or use --force.`, + ); + } + } + + if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) { + problems.push( + `Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`, + ); + } + + if (problems.length > 0 && !opts.force) { + for (const problem of problems) { + p.log.error(problem); + } + throw new Error("Safety checks failed. Resolve the issues above or re-run with --force."); + } + if (problems.length > 0 && opts.force) { + for (const problem of problems) { + p.log.warning(`Overridden by --force: ${problem}`); + } + } + + // ── 3. Clean up (idempotent steps) ────────────────────────────────── + + // 3a. Remove the git worktree registration + if (linkedWorktree) { + const worktreeDirExists = existsSync(linkedWorktree.worktree); + const spinner = p.spinner(); + if (worktreeDirExists) { + spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`); + try { + const removeArgs = ["worktree", "remove", linkedWorktree.worktree]; + if (opts.force) removeArgs.push("--force"); + execFileSync("git", removeArgs, { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`); + } catch (error) { + spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`)); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + } else { + spinner.start("Pruning stale worktree entry..."); + execFileSync("git", ["worktree", "prune"], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop("Pruned stale worktree entry."); + } + } else { + // Even without a linked worktree, prune to clean up any orphaned entries + execFileSync("git", ["worktree", "prune"], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + } + + // 3b. Remove the worktree directory if it still exists (e.g. partial creation) + if (existsSync(targetPath)) { + const spinner = p.spinner(); + spinner.start(`Removing worktree directory ${targetPath}...`); + rmSync(targetPath, { recursive: true, force: true }); + spinner.stop(`Removed worktree directory ${targetPath}.`); + } + + // 3c. Delete the local branch (now safe — worktree is gone) + if (localBranchExists(sourceCwd, name)) { + const spinner = p.spinner(); + spinner.start(`Deleting local branch "${name}"...`); + try { + const deleteFlag = opts.force ? "-D" : "-d"; + execFileSync("git", ["branch", deleteFlag, name], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Deleted local branch "${name}".`); + } catch (error) { + spinner.stop(pc.yellow(`Could not delete branch "${name}".`)); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + } + + // 3d. Remove instance data + if (existsSync(instanceRoot)) { + const spinner = p.spinner(); + spinner.start(`Removing instance data at ${instanceRoot}...`); + rmSync(instanceRoot, { recursive: true, force: true }); + spinner.stop(`Removed instance data at ${instanceRoot}.`); + } + + p.outro(pc.green("Cleanup complete.")); +} + +export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { + const configPath = resolveConfigPath(opts.config); + const envPath = resolvePaperclipEnvFile(configPath); + const envEntries = readPaperclipEnvEntries(envPath); + const out = { + PAPERCLIP_CONFIG: configPath, + ...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}), + ...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}), + ...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}), + ...envEntries, + }; + + if (opts.json) { + console.log(JSON.stringify(out, null, 2)); + return; + } + + console.log(formatShellExports(out)); +} + +export function registerWorktreeCommands(program: Command): void { + const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers"); + + program + .command("worktree:make") + .description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it") + .argument("", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)") + .option("--start-point ", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)") + .option("--instance ", "Explicit isolated instance id") + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config", "default") + .option("--server-port ", "Preferred server port", (value) => Number(value)) + .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--no-seed", "Skip database seeding from the source instance") + .option("--force", "Replace existing repo-local config and isolated instance data", false) + .action(worktreeMakeCommand); + + worktree + .command("init") + .description("Create repo-local config/env and an isolated instance for this worktree") + .option("--name ", "Display name used to derive the instance id") + .option("--instance ", "Explicit isolated instance id") + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config", "default") + .option("--server-port ", "Preferred server port", (value) => Number(value)) + .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--no-seed", "Skip database seeding from the source instance") + .option("--force", "Replace existing repo-local config and isolated instance data", false) + .action(worktreeInitCommand); + + worktree + .command("env") + .description("Print shell exports for the current worktree-local Paperclip instance") + .option("-c, --config ", "Path to config file") + .option("--json", "Print JSON instead of shell exports") + .action(worktreeEnvCommand); + + program + .command("worktree:cleanup") + .description("Safely remove a worktree, its branch, and its isolated instance data") + .argument("", "Worktree name — auto-prefixed with paperclip- if needed") + .option("--instance ", "Explicit instance id (if different from the worktree name)") + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false) + .action(worktreeCleanupCommand); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 908907ba..a7266ea2 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -22,20 +22,35 @@ function parseEnvFile(contents: string) { } } +function formatEnvValue(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) { + return value; + } + return JSON.stringify(value); +} + function renderEnvFile(entries: Record) { const lines = [ "# Paperclip environment variables", - "# Generated by `paperclipai onboard`", - ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), + "# Generated by Paperclip CLI commands", + ...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`), "", ]; return lines.join("\n"); } +export function resolvePaperclipEnvFile(configPath?: string): string { + return resolveEnvFilePath(configPath); +} + export function resolveAgentJwtEnvFile(configPath?: string): string { return resolveEnvFilePath(configPath); } +export function loadPaperclipEnvFile(configPath?: string): void { + loadAgentJwtEnvFile(resolveEnvFilePath(configPath)); +} + export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { if (loadedEnvFiles.has(filePath)) return; @@ -78,13 +93,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre } export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { + mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath); +} + +export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record { + if (!fs.existsSync(filePath)) return {}; + return parseEnvFile(fs.readFileSync(filePath, "utf-8")); +} + +export function writePaperclipEnvEntries(entries: Record, filePath = resolveEnvFilePath()): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); - - const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {}; - current[JWT_SECRET_ENV_KEY] = secret; - - fs.writeFileSync(filePath, renderEnvFile(current), { + fs.writeFileSync(filePath, renderEnvFile(entries), { mode: 0o600, }); } + +export function mergePaperclipEnvEntries( + entries: Record, + filePath = resolveEnvFilePath(), +): Record { + const current = readPaperclipEnvEntries(filePath); + const next = { + ...current, + ...Object.fromEntries( + Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0), + ), + }; + writePaperclipEnvEntries(next, filePath); + return next; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 9c31f5ae..628cd7e7 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -16,6 +16,9 @@ import { registerApprovalCommands } from "./commands/client/approval.js"; import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; +import { loadPaperclipEnvFile } from "./config/env.js"; +import { registerWorktreeCommands } from "./commands/worktree.js"; +import { registerPluginCommands } from "./commands/client/plugin.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -33,6 +36,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => { hasConfigOption: optionNames.has("config"), hasContextOption: optionNames.has("context"), }); + loadPaperclipEnvFile(options.config); }); program @@ -132,6 +136,8 @@ registerAgentCommands(program); registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); +registerWorktreeCommands(program); +registerPluginCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index c2ab4218..e5c26180 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -113,7 +113,7 @@ export async function promptServer(opts?: { } const port = Number(portStr) || 3100; - let auth: AuthConfig = { baseUrlMode: "auto" }; + let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false }; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", @@ -139,11 +139,13 @@ export async function promptServer(opts?: { } auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), }; } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: currentAuth.publicBaseUrl, }; } @@ -160,4 +162,3 @@ export async function promptServer(opts?: { auth, }; } - diff --git a/cli/tsconfig.json b/cli/tsconfig.json index e4600622..dc664efe 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/doc/CLI.md b/doc/CLI.md index b56abf75..6f945656 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -116,6 +116,20 @@ pnpm paperclipai issue release ```sh pnpm paperclipai agent list --company-id pnpm paperclipai agent get +pnpm paperclipai agent local-cli --company-id +``` + +`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: + +- creates a new long-lived agent API key +- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills` +- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY` + +Example for shortname-based local setup: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +pnpm paperclipai agent local-cli claudecoder --company-id ``` ## Approval Commands diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 8f46f382..d2425d14 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -19,6 +19,14 @@ That's it. On first start the server: Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory. +If you need to apply pending migrations manually, run: + +```sh +pnpm db:migrate +``` + +When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance. + This mode is ideal for local development and one-command installs. Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`). diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index bb287623..b39839c1 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -15,6 +15,14 @@ Current implementation status: - Node.js 20+ - pnpm 9+ +## Dependency Lockfile Policy + +GitHub Actions owns `pnpm-lock.yaml`. + +- Do not commit `pnpm-lock.yaml` in pull requests. +- Pull request CI validates dependency resolution when manifests change. +- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`. + ## Start Dev From repo root: @@ -81,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details. +## Docker For Untrusted PR Review + +For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`. + ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. @@ -116,6 +128,119 @@ When a local agent run has no resolved project/session workspace, Paperclip fall This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. +## Worktree-local Instances + +When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. + +Instead, create a repo-local Paperclip config plus an isolated instance for the worktree: + +```sh +paperclipai worktree init +# or create the git worktree and initialize it in one step: +pnpm paperclipai worktree:make paperclip-pr-432 +``` + +This command: + +- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env` +- creates an isolated instance under `~/.paperclip-worktrees/instances//` +- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir +- picks a free app port and embedded PostgreSQL port +- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot + +Seed modes: + +- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state +- `full` makes a full logical clone of the source instance +- `--no-seed` creates an empty isolated instance + +After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. + +That repo-local env also sets: + +- `PAPERCLIP_IN_WORKTREE=true` +- `PAPERCLIP_WORKTREE_NAME=` +- `PAPERCLIP_WORKTREE_COLOR=` + +The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon. + +Print shell exports explicitly when needed: + +```sh +paperclipai worktree env +# or: +eval "$(paperclipai worktree env)" +``` + +### Worktree CLI Reference + +**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree. + +| Option | Description | +|---|---| +| `--name ` | Display name used to derive the instance id | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: + +```sh +paperclipai worktree init --no-seed +paperclipai worktree init --seed-mode full +paperclipai worktree init --from-instance default +paperclipai worktree init --from-data-dir ~/.paperclip +paperclipai worktree init --force +``` + +**`pnpm paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. + +| Option | Description | +|---|---| +| `--start-point ` | Remote ref to base the new branch on (e.g. `origin/main`) | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: + +```sh +pnpm paperclipai worktree:make paperclip-pr-432 +pnpm paperclipai worktree:make my-feature --start-point origin/main +pnpm paperclipai worktree:make experiment --no-seed +``` + +**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance. + +| Option | Description | +|---|---| +| `-c, --config ` | Path to config file | +| `--json` | Print JSON instead of shell exports | + +Examples: + +```sh +pnpm paperclipai worktree env +pnpm paperclipai worktree env --json +eval "$(pnpm paperclipai worktree env)" +``` + +For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. + ## Quick Health Checks In another terminal: diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 49d0c4ab..6f6ca374 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -93,6 +93,12 @@ Notes: - Without API keys, the app still runs normally. - Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites. +## Untrusted PR Review Container + +If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`. + +That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs. + ## Onboard Smoke Test (Ubuntu + npm only) Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify: @@ -122,5 +128,7 @@ Notes: - Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime. - Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host. - Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`. +- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`. +- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access. - Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation. - The image definition is in `Dockerfile.onboard-smoke`. diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md new file mode 100644 index 00000000..bdb098b3 --- /dev/null +++ b/doc/OPENCLAW_ONBOARDING.md @@ -0,0 +1,94 @@ +Use this exact checklist. + +1. Start Paperclip in auth mode. +```bash +cd +pnpm dev --tailscale-auth +``` +Then verify: +```bash +curl -sS http://127.0.0.1:3100/api/health | jq +``` + +2. Start a clean/stock OpenClaw Docker. +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +Open the printed `Dashboard URL` (includes `#token=...`) in your browser. + +3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. + +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. +- Paste it into OpenClaw main chat as one message. +- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` + +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + +5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. + +6. Gateway preflight (required before task tests). +- Confirm the created agent uses `openclaw_gateway` (not `openclaw`). +- Confirm gateway URL is `ws://...` or `wss://...`. +- Confirm gateway token is non-trivial (not empty / not 1-char placeholder). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. +- Confirm pairing mode is explicit: + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding +- If you can run API checks with board auth: +```bash +AGENT_ID="" +curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}' +``` +- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. + +Pairing handshake note: +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. +- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. +- Approve it in OpenClaw, then retry the task. +- For local docker smoke, you can approve from host: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' +``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` + +7. Case A (manual issue test). +- Create an issue assigned to the OpenClaw agent. +- Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” +- Verify in UI: issue status becomes `done` and comment exists. + +8. Case B (message tool test). +- Create another issue assigned to OpenClaw. +- Instructions: “send `OPENCLAW_CASE_B_OK_` to main webchat via message tool, then comment same marker on issue, then mark done.” +- Verify both: + - marker comment on issue + - marker text appears in OpenClaw main chat + +9. Case C (new session memory/skills test). +- In OpenClaw, start `/new` session. +- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_`. +- Verify in Paperclip UI that new issue exists. + +10. Watch logs during test (optional but helpful): +```bash +docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway +``` + +11. Expected pass criteria. +- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`). +- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path). +- Case A: `done` + marker comment. +- Case B: `done` + marker comment + main-chat message visible. +- Case C: original task done and new issue created from `/new` session. + +If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI. diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 741df662..f835889c 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -94,3 +94,53 @@ Canonical mode design and command expectations live in `doc/DEPLOYMENT-MODES.md` ## Further Detail See [SPEC.md](./SPEC.md) for the full technical specification and [TASKS.md](./TASKS.md) for the task management data model. + +--- + +Paperclip’s core identity is a **control plane for autonomous AI companies**, centered on **companies, org charts, goals, issues/comments, heartbeats, budgets, approvals, and board governance**. The public docs are also explicit about the current boundaries: **tasks/comments are the built-in communication model**, Paperclip is **not a chatbot**, and it is **not a code review tool**. The roadmap already points toward **easier onboarding, cloud agents, easier agent configuration, plugins, better docs, and ClipMart/ClipHub-style reusable companies/templates**. + +## What Paperclip should do vs. not do + +**Do** + +- Stay **board-level and company-level**. Users should manage goals, orgs, budgets, approvals, and outputs. +- Make the first five minutes feel magical: install, answer a few questions, see a CEO do something real. +- Keep work anchored to **issues/comments/projects/goals**, even if the surface feels conversational. +- Treat **agency / internal team / startup** as the same underlying abstraction with different templates and labels. +- Make outputs first-class: files, docs, reports, previews, links, screenshots. +- Provide **hooks into engineering workflows**: worktrees, preview servers, PR links, external review tools. +- Use **plugins** for edge cases like rich chat, knowledge bases, doc editors, custom tracing. + +**Do not** + +- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. +- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. +- Do not build enterprise-grade RBAC first. The current V1 spec still treats multi-board governance and fine-grained human permissions as out of scope, so the first multi-user version should be coarse and company-scoped. +- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. +- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. + +## Specific design goals + +1. **Time-to-first-success under 5 minutes** + A fresh user should go from install to “my CEO completed a first task” in one sitting. + +2. **Board-level abstraction always wins** + The default UI should answer: what is the company doing, who is doing it, why does it matter, what did it cost, and what needs my approval. + +3. **Conversation stays attached to work objects** + “Chat with CEO” should still resolve to strategy threads, decisions, tasks, or approvals. + +4. **Progressive disclosure** + Top layer: human-readable summary. Middle layer: checklist/steps/artifacts. Bottom layer: raw logs/tool calls/transcript. + +5. **Output-first** + Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR. + +6. **Local-first, cloud-ready** + The mental model should not change between local solo use and shared/private or public/cloud deployment. + +7. **Safe autonomy** + Auto mode is allowed; hidden token burn is not. + +8. **Thin core, rich edges** + Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane. diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 29ac7291..9e8befb3 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -1,196 +1,121 @@ # Publishing to npm -This document covers how to build and publish the `paperclipai` CLI package to npm. +Low-level reference for how Paperclip packages are built for npm. -## Prerequisites +For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts. -- Node.js 20+ -- pnpm 9.15+ -- An npm account with publish access to the `paperclipai` package -- Logged in to npm: `npm login` +## Current Release Entry Points -## One-Command Publish +Use these scripts instead of older one-off publish commands: -The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot: +- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z` +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release +- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag -```bash -./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2 -./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0 -./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0 -./scripts/bump-and-publish.sh 2.0.0 # set explicit version -./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish -``` +## Why the CLI needs special packaging -The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push: +The CLI package, `paperclipai`, imports code from workspace packages such as: -```bash -git push && git push origin v -``` +- `@paperclipai/server` +- `@paperclipai/db` +- `@paperclipai/shared` +- adapter packages under `packages/adapters/` -## Manual Step-by-Step +Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package. -If you prefer to run each step individually: +## `build-npm.sh` -### Quick Reference - -```bash -# Bump version -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 - -# Build -./scripts/build-npm.sh - -# Preview what will be published -cd cli && npm pack --dry-run - -# Publish -cd cli && npm publish --access public - -# Restore dev package.json -mv cli/package.dev.json cli/package.json -``` - -## Step-by-Step - -### 1. Bump the version - -```bash -./scripts/version-bump.sh -``` - -This updates the version in two places: - -- `cli/package.json` — the source of truth -- `cli/src/index.ts` — the Commander `.version()` call - -Examples: - -```bash -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 -./scripts/version-bump.sh minor # 0.1.0 → 0.2.0 -./scripts/version-bump.sh major # 0.1.0 → 1.0.0 -./scripts/version-bump.sh 1.2.3 # set explicit version -``` - -### 2. Build +Run: ```bash ./scripts/build-npm.sh ``` -The build script runs five steps: +This script does six things: -1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for. -2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages. -3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports. -4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below). -5. **Summary** — prints the bundle size and next steps. +1. Runs the forbidden token check unless `--skip-checks` is supplied +2. Runs `pnpm -r typecheck` +3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js` +4. Verifies the bundled entrypoint with `node --check` +5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json` +6. Copies the repo `README.md` into `cli/README.md` for npm package metadata -To skip the forbidden token check (e.g. in CI without the token list): +`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies. + +## Publishable CLI layout + +During development, [`cli/package.json`](../cli/package.json) contains workspace references. + +During release preparation: + +- `cli/package.json` becomes a publishable manifest with external npm dependency ranges +- `cli/package.dev.json` stores the development manifest temporarily +- `cli/dist/index.js` contains the bundled CLI entrypoint +- `cli/README.md` is copied in for npm metadata + +After release finalization, the release script restores the development manifest and removes the temporary README copy. + +## Package discovery + +The release tooling scans the workspace for public packages under: + +- `packages/` +- `server/` +- `cli/` + +`ui/` remains ignored for npm publishing because it is private. + +This matters because all public packages are versioned and published together as one release unit. + +## Canary packaging model + +Canaries are published as semver prereleases such as: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` + +They are published under the npm dist-tag `canary`. + +This means: + +- `npx paperclipai@canary onboard` can install them explicitly +- `npx paperclipai onboard` continues to resolve `latest` +- the stable changelog can stay at `releases/v1.2.3.md` + +## Stable packaging model + +Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. + +The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps. + +## Rollback model + +Rollback does not unpublish packages. + +Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with: ```bash -./scripts/build-npm.sh --skip-checks +./scripts/rollback-latest.sh ``` -### 3. Preview (optional) +That keeps history intact while restoring the default install path quickly. -See what npm will publish: +## Notes for CI -```bash -cd cli && npm pack --dry-run -``` +The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). -### 4. Publish +Recommended CI release setup: -```bash -cd cli && npm publish --access public -``` +- use npm trusted publishing via GitHub OIDC +- require approval through the `npm-release` environment +- run releases from `release/X.Y.Z` +- use canary first, then stable -### 5. Restore dev package.json +## Related Files -After publishing, restore the workspace-aware `package.json`: - -```bash -mv cli/package.dev.json cli/package.json -``` - -### 6. Commit and tag - -```bash -git add cli/package.json cli/src/index.ts -git commit -m "chore: bump version to X.Y.Z" -git tag vX.Y.Z -``` - -## package.dev.json - -During development, `cli/package.json` contains `workspace:*` references like: - -```json -{ - "dependencies": { - "@paperclipai/server": "workspace:*", - "@paperclipai/db": "workspace:*" - } -} -``` - -These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users. - -The build script solves this with a two-file swap: - -1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version). -2. **During build (`build-npm.sh` step 4):** - - The dev `package.json` is copied to `package.dev.json` as a backup. - - `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs. -3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`. - -The generated publishable `package.json` looks like: - -```json -{ - "name": "paperclipai", - "version": "0.1.0", - "bin": { "paperclipai": "./dist/index.js" }, - "dependencies": { - "express": "^5.1.0", - "postgres": "^3.4.5", - "commander": "^13.1.0" - } -} -``` - -`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle. - -## How the bundle works - -The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm. - -**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`. - -The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle). - -## Forbidden token enforcement - -The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm. - -- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported) -- The file lives inside `.git/` and is never committed -- If the file is missing, the check passes — contributors without the list can still build -- The script never prints which tokens are being searched for -- Matches are printed so you know which files to fix, but not which token triggered it - -Run the check standalone: - -```bash -pnpm check:tokens -``` - -## npm scripts reference - -| Script | Command | Description | -|---|---|---| -| `bump-and-publish` | `pnpm bump-and-publish ` | One-command bump + build + publish + commit + tag | -| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) | -| `version:bump` | `pnpm version:bump ` | Bump CLI version | -| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only | +- [`scripts/build-npm.sh`](../scripts/build-npm.sh) +- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) +- [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASING.md b/doc/RELEASING.md new file mode 100644 index 00000000..69d17366 --- /dev/null +++ b/doc/RELEASING.md @@ -0,0 +1,422 @@ +# Releasing Paperclip + +Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. + +The release model is branch-driven: + +1. Start a release train on `release/X.Y.Z` +2. Draft the stable changelog on that branch +3. Publish one or more canaries from that branch +4. Publish stable from that same branch head +5. Push the branch commit and tag +6. Create the GitHub Release +7. Merge `release/X.Y.Z` back to `master` without squash or rebase + +## Release Surfaces + +Every release has four separate surfaces: + +1. **Verification** — the exact git SHA passes typecheck, tests, and build +2. **npm** — `paperclipai` and public workspace packages are published +3. **GitHub** — the stable release gets a git tag and GitHub Release +4. **Website / announcements** — the stable changelog is published externally and announced + +A release is done only when all four surfaces are handled. + +## Core Invariants + +- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch. +- The release scripts must run from the matching `release/X.Y.Z` branch. +- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen. +- Do not squash-merge or rebase-merge a release branch PR back to `master`. +- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files. + +The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property. + +## TL;DR + +### 1. Start the release train + +Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub. + +```bash +./scripts/release-start.sh patch +``` + +That script: + +- fetches the release remote and tags +- computes the next stable version from the latest `v*` tag +- creates or resumes `release/X.Y.Z` +- creates or resumes a dedicated worktree +- pushes the branch to the remote by default +- refuses to reuse a frozen release train + +### 2. Draft the stable changelog + +From the release worktree: + +```bash +VERSION=X.Y.Z +claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +``` + +### 3. Verify and publish a canary + +```bash +./scripts/release-preflight.sh canary patch +./scripts/release.sh patch --canary --dry-run +./scripts/release.sh patch --canary +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Users install canaries with: + +```bash +npx paperclipai@canary onboard +``` + +### 4. Publish stable + +```bash +./scripts/release-preflight.sh stable patch +./scripts/release.sh patch --dry-run +./scripts/release.sh patch +git push public-gh HEAD --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase. + +## Release Branches + +Paperclip uses one release branch per target stable version: + +- `release/0.3.0` +- `release/0.3.1` +- `release/1.0.0` + +Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train. + +## Script Entry Points + +- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate +- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version + +## Detailed Workflow + +### 1. Start or resume the release train + +Run: + +```bash +./scripts/release-start.sh +``` + +Useful options: + +```bash +./scripts/release-start.sh patch --dry-run +./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0 +./scripts/release-start.sh patch --no-push +``` + +The script is intentionally idempotent: + +- if `release/X.Y.Z` already exists locally, it reuses it +- if the branch already exists on the remote, it resumes it locally +- if the branch is already checked out in another worktree, it points you there +- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train + +### 2. Write the stable changelog early + +Create or update: + +- `releases/vX.Y.Z.md` + +That file is for the eventual stable release. It should not include `-canary` in the filename or heading. + +Recommended structure: + +- `Breaking Changes` when needed +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed +- `Contributors` — @-mention every contributor by GitHub username (no emails) + +Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative. + +### 3. Run release preflight + +From the `release/X.Y.Z` worktree: + +```bash +./scripts/release-preflight.sh canary +# or +./scripts/release-preflight.sh stable +``` + +The preflight script now checks all of the following before it runs the verification gate: + +- the worktree is clean, including untracked files +- the current branch matches the computed `release/X.Y.Z` +- the release train is not frozen +- the target version is still free on npm +- the target tag does not already exist locally or remotely +- whether the remote release branch already exists +- whether `releases/vX.Y.Z.md` is present + +Then it runs: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +### 4. Publish one or more canaries + +Run: + +```bash +./scripts/release.sh --canary --dry-run +./scripts/release.sh --canary +``` + +Result: + +- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` +- `latest` is unchanged +- no git tag is created +- no GitHub Release is created +- the worktree returns to clean after the script finishes + +Guardrails: + +- the script refuses to run from the wrong branch +- the script refuses to publish from a frozen train +- the canary is always derived from the next stable version +- if the stable notes file is missing, the script warns before you forget it + +Concrete example: + +- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0` +- `0.2.7-canary.N` is invalid because `0.2.7` is already stable + +### 5. Smoke test the canary + +Run the actual install path in Docker: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Useful isolated variants: + +```bash +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +If you want to exercise onboarding from the current committed ref instead of npm, use: + +```bash +./scripts/clean-onboard-ref.sh +PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh +./scripts/clean-onboard-ref.sh HEAD +``` + +Minimum checks: + +- `npx paperclipai@canary onboard` installs +- onboarding completes without crashes +- the server boots +- the UI loads +- basic company creation and dashboard load work + +If smoke testing fails: + +1. stop the stable release +2. fix the issue on the same `release/X.Y.Z` branch +3. publish another canary +4. rerun smoke testing + +### 6. Publish stable from the same release branch + +Once the branch head is vetted, run: + +```bash +./scripts/release.sh --dry-run +./scripts/release.sh +``` + +Stable publish: + +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local tag `vX.Y.Z` + +Stable publish refuses to proceed if: + +- the current branch is not `release/X.Y.Z` +- the remote release branch does not exist yet +- the stable notes file is missing +- the target tag already exists locally or remotely +- the stable version already exists on npm + +Those checks intentionally freeze the train after stable publish. + +### 7. Push the stable branch commit and tag + +After stable publish succeeds: + +```bash +git push public-gh HEAD --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +The GitHub Release notes come from: + +- `releases/vX.Y.Z.md` + +### 8. Merge the release branch back to `master` + +Open a PR: + +- base: `master` +- head: `release/X.Y.Z` + +Merge rule: + +- allowed: merge commit or fast-forward +- forbidden: squash merge +- forbidden: rebase merge + +Post-merge verification: + +```bash +git fetch public-gh --tags +git merge-base --is-ancestor "vX.Y.Z" "public-gh/master" +``` + +That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong. + +### 9. Finish the external surfaces + +After GitHub is correct: + +- publish the changelog on the website +- write and send the announcement copy +- ensure public docs and install guidance point to the stable version + +## GitHub Actions Release + +There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). + +Use it from the Actions tab on the relevant `release/X.Y.Z` branch: + +1. Choose `Release` +2. Choose `channel`: `canary` or `stable` +3. Choose `bump`: `patch`, `minor`, or `major` +4. Choose whether this is a `dry_run` +5. Run it from the release branch, not from `master` + +The workflow: + +- reruns `typecheck`, `test:run`, and `build` +- gates publish behind the `npm-release` environment +- can publish canaries without touching `latest` +- can publish stable, push the stable branch commit and tag, and create the GitHub Release + +It does not merge the release branch back to `master` for you. + +## Release Checklist + +### Before any publish + +- [ ] The release train exists on `release/X.Y.Z` +- [ ] The working tree is clean, including untracked files +- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut +- [ ] The required verification gate passed on the exact branch head you want to publish +- [ ] The bump type is correct for the user-visible impact +- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md` +- [ ] You know which previous stable version you would roll back to if needed + +### Before a stable + +- [ ] The candidate has already passed smoke testing +- [ ] The remote `release/X.Y.Z` branch exists +- [ ] You are ready to push the stable branch commit and tag immediately after npm publish +- [ ] You are ready to create the GitHub Release immediately after the push +- [ ] You are ready to open the PR back to `master` + +### After a stable + +- [ ] `npm view paperclipai@latest version` matches the new stable version +- [ ] The git tag exists on GitHub +- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` +- [ ] `vX.Y.Z` is reachable from `master` +- [ ] The website changelog is updated +- [ ] Announcement copy matches the stable release, not the canary + +## Failure Playbooks + +### If the canary publishes but the smoke test fails + +Do not publish stable. + +Instead: + +1. fix the issue on `release/X.Y.Z` +2. publish another canary +3. rerun smoke testing + +### If stable npm publish succeeds but push or GitHub release creation fails + +This is a partial release. npm is already live. + +Do this immediately: + +1. fix the git or GitHub issue from the same checkout +2. push the stable branch commit and tag +3. create the GitHub Release + +Do not republish the same version. + +### If `latest` is broken after stable publish + +Preview: + +```bash +./scripts/rollback-latest.sh X.Y.Z --dry-run +``` + +Roll back: + +```bash +./scripts/rollback-latest.sh X.Y.Z +``` + +This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. + +Then fix forward with a new patch release. + +### If the GitHub Release notes are wrong + +Re-run: + +```bash +./scripts/create-github-release.sh X.Y.Z +``` + +If the release already exists, the script updates it. + +## Related Docs + +- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals +- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow +- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 430dcabb..7a4b1cbc 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1. | Visibility | Full visibility to board and all agents in same company | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | -| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed | +| Recovery | No automatic reassignment; work recovery stays manual/explicit | | Agent adapters | Built-in `process` and `http` adapters | | Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | | Budget period | Monthly UTC calendar window | @@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles: - heartbeat trigger checks - stuck run detection - budget threshold checks -- stale task reporting generation Separate queue infrastructure is not required for V1. @@ -331,6 +330,34 @@ Operational policy: - `asset_id` uuid fk not null - `issue_comment_id` uuid fk null +## 7.15 `documents` + `document_revisions` + `issue_documents` + +- `documents` stores editable text-first documents: + - `id` uuid pk + - `company_id` uuid fk not null + - `title` text null + - `format` text not null (`markdown`) + - `latest_body` text not null + - `latest_revision_id` uuid null + - `latest_revision_number` int not null + - `created_by_agent_id` uuid fk null + - `created_by_user_id` uuid/text fk null + - `updated_by_agent_id` uuid fk null + - `updated_by_user_id` uuid/text fk null +- `document_revisions` stores append-only history: + - `id` uuid pk + - `company_id` uuid fk not null + - `document_id` uuid fk not null + - `revision_number` int not null + - `body` text not null + - `change_summary` text null +- `issue_documents` links documents to issues with a stable workflow key: + - `id` uuid pk + - `company_id` uuid fk not null + - `issue_id` uuid fk not null + - `document_id` uuid fk not null + - `key` text not null (`plan`, `design`, `notes`, etc.) + ## 8. State Machines ## 8.1 Agent Status @@ -442,6 +469,11 @@ All endpoints are under `/api` and return JSON. - `POST /companies/:companyId/issues` - `GET /issues/:issueId` - `PATCH /issues/:issueId` +- `GET /issues/:issueId/documents` +- `GET /issues/:issueId/documents/:key` +- `PUT /issues/:issueId/documents/:key` +- `GET /issues/:issueId/documents/:key/revisions` +- `DELETE /issues/:issueId/documents/:key` - `POST /issues/:issueId/checkout` - `POST /issues/:issueId/release` - `POST /issues/:issueId/comments` @@ -502,7 +534,6 @@ Dashboard payload must include: - open/in-progress/blocked/done issue counts - month-to-date spend and budget utilization - pending approvals count -- stale task count ## 10.9 Error Semantics @@ -681,7 +712,6 @@ Required UX behaviors: - global company selector - quick actions: pause/resume agent, create task, approve/reject request - conflict toasts on atomic checkout failure -- clear stale-task indicators - no silent background failures; every failed run visible in UI ## 15. Operational Requirements @@ -780,7 +810,6 @@ A release candidate is blocked unless these pass: - add company selector and org chart view - add approvals and cost pages -- add operational dashboard and stale-task surfacing ## Milestone 6: Hardening and Release diff --git a/doc/SPEC.md b/doc/SPEC.md index 33c24b3a..82315bce 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -188,12 +188,15 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters: -| Adapter | Mechanism | Example | -| --------- | ----------------------- | --------------------------------------------- | -| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | -| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| Adapter | Mechanism | Example | +| -------------------- | ----------------------- | --------------------------------------------- | +| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | +| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | +| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval | +| `hermes_local` | Hermes agent process | Local Hermes agent | -The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture). +The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). ### Adapter Interface @@ -429,7 +432,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext - **Agent Adapter plugins** — new Adapter types can be registered via the plugin system - Plugin-registrable UI components (future) -This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible. +The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins. --- diff --git a/doc/UNTRUSTED-PR-REVIEW.md b/doc/UNTRUSTED-PR-REVIEW.md new file mode 100644 index 00000000..0061a581 --- /dev/null +++ b/doc/UNTRUSTED-PR-REVIEW.md @@ -0,0 +1,135 @@ +# Untrusted PR Review In Docker + +Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly. + +This is intentionally separate from the normal Paperclip dev image. + +## What this container isolates + +- `codex` auth/session state in a Docker volume, not your host `~/.codex` +- `claude` auth/session state in a Docker volume, not your host `~/.claude` +- `gh` auth state in the same container-local home volume +- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work` + +By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent. + +## Files + +- `docker/untrusted-review/Dockerfile` +- `docker-compose.untrusted-review.yml` +- `review-checkout-pr` inside the container + +## Build and start a shell + +```sh +docker compose -f docker-compose.untrusted-review.yml build +docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review +``` + +That opens an interactive shell in the review container with: + +- Node + Corepack/pnpm +- `codex` +- `claude` +- `gh` +- `git`, `rg`, `fd`, `jq` + +## First-time login inside the container + +Run these once. The resulting login state persists in the `review-home` Docker volume. + +```sh +gh auth login +codex login +claude login +``` + +If you prefer API-key auth instead of CLI login, pass keys through Compose env: + +```sh +OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review +``` + +## Check out a PR safely + +Inside the container: + +```sh +review-checkout-pr paperclipai/paperclip 432 +cd /work/checkouts/paperclipai-paperclip/pr-432 +``` + +What this does: + +1. Creates or reuses a repo clone under `/work/repos/...` +2. Fetches `pull//head` from GitHub +3. Creates a detached git worktree under `/work/checkouts/...` + +The checkout lives entirely inside the container volume. + +## Ask Codex or Claude to review it + +Inside the PR checkout: + +```sh +codex +``` + +Then give it a prompt like: + +```text +Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references. +``` + +Or with Claude: + +```sh +claude +``` + +## Preview the Paperclip app from the PR + +Only do this when you intentionally want to execute the PR's code inside the container. + +Inside the PR checkout: + +```sh +pnpm install +HOST=0.0.0.0 pnpm dev +``` + +Open from the host: + +- `http://localhost:3100` + +The Compose file also exposes Vite's default port: + +- `http://localhost:5173` + +Notes: + +- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host. +- If you only want static inspection, do not run install/dev commands. +- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`. + +## Reset state + +Remove the review container volumes when you want a clean environment: + +```sh +docker compose -f docker-compose.untrusted-review.yml down -v +``` + +That deletes: + +- Codex/Claude/GitHub login state stored in `review-home` +- cloned repos, worktrees, installs, and scratch data stored in `review-work` + +## Security limits + +This is a useful isolation boundary, but it is still Docker, not a full VM. + +- A reviewed PR can still access the container's network unless you disable it. +- Any secrets you pass into the container are available to code you execute inside it. +- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary. +- If you need a stronger boundary than this, use a disposable VM instead of Docker. diff --git a/doc/experimental/issue-worktree-support.md b/doc/experimental/issue-worktree-support.md new file mode 100644 index 00000000..8f05ff19 --- /dev/null +++ b/doc/experimental/issue-worktree-support.md @@ -0,0 +1,62 @@ +# Issue worktree support + +Status: experimental, runtime-only, not shipping as a user-facing feature yet. + +This branch contains the runtime and seeding work needed for issue-scoped worktrees: + +- project execution workspace policy support +- issue-level execution workspace settings +- git worktree realization for isolated issue execution +- optional command-based worktree provisioning +- seeded worktree fixes for secrets key compatibility +- seeded project workspace rebinding to the current git worktree + +We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now. + +## What works today + +- projects can carry execution workspace policy in the backend +- issues can carry execution workspace settings in the backend +- heartbeat execution can realize isolated git worktrees +- runtime can run a project-defined provision command inside the derived worktree +- seeded worktree instances can keep local-encrypted secrets working +- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree + +## Hidden UI entrypoints + +These are the current user-facing UI surfaces for the feature, now intentionally disabled: + +- project settings: + - `ui/src/components/ProjectProperties.tsx` + - execution workspace policy controls + - git worktree base ref / branch template / parent dir + - provision / teardown command inputs + +- issue creation: + - `ui/src/components/NewIssueDialog.tsx` + - isolated issue checkout toggle + - defaulting issue execution workspace settings from project policy + +- issue editing: + - `ui/src/components/IssueProperties.tsx` + - issue-level workspace mode toggle + - defaulting issue execution workspace settings when project changes + +- agent/runtime settings: + - `ui/src/adapters/runtime-json-fields.tsx` + - runtime services JSON field, which is part of the broader workspace-runtime support surface + +## Why the UI is hidden + +- the runtime behavior is still being validated +- the workflow and operator ergonomics are not final +- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings + +## Re-enable plan + +When this is ready to ship: + +- re-enable the gated UI sections in the files above +- review wording and defaults for project and issue controls +- decide which agent/runtime settings should remain advanced-only +- add end-to-end product-level verification for the full UI workflow diff --git a/doc/plans/module-system.md b/doc/plans/2026-02-16-module-system.md similarity index 100% rename from doc/plans/module-system.md rename to doc/plans/2026-02-16-module-system.md diff --git a/doc/plans/agent-authentication-implementation.md b/doc/plans/2026-02-18-agent-authentication-implementation.md similarity index 100% rename from doc/plans/agent-authentication-implementation.md rename to doc/plans/2026-02-18-agent-authentication-implementation.md diff --git a/doc/plans/agent-authentication.md b/doc/plans/2026-02-18-agent-authentication.md similarity index 100% rename from doc/plans/agent-authentication.md rename to doc/plans/2026-02-18-agent-authentication.md diff --git a/doc/plans/agent-mgmt-followup-plan.md b/doc/plans/2026-02-19-agent-mgmt-followup-plan.md similarity index 100% rename from doc/plans/agent-mgmt-followup-plan.md rename to doc/plans/2026-02-19-agent-mgmt-followup-plan.md diff --git a/doc/plans/ceo-agent-creation-and-hiring.md b/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md similarity index 100% rename from doc/plans/ceo-agent-creation-and-hiring.md rename to doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md diff --git a/doc/plans/issue-run-orchestration-plan.md b/doc/plans/2026-02-20-issue-run-orchestration-plan.md similarity index 100% rename from doc/plans/issue-run-orchestration-plan.md rename to doc/plans/2026-02-20-issue-run-orchestration-plan.md diff --git a/doc/plans/storage-system-implementation.md b/doc/plans/2026-02-20-storage-system-implementation.md similarity index 100% rename from doc/plans/storage-system-implementation.md rename to doc/plans/2026-02-20-storage-system-implementation.md diff --git a/doc/plan/humans-and-permissions-implementation.md b/doc/plans/2026-02-21-humans-and-permissions-implementation.md similarity index 100% rename from doc/plan/humans-and-permissions-implementation.md rename to doc/plans/2026-02-21-humans-and-permissions-implementation.md diff --git a/doc/plan/humans-and-permissions.md b/doc/plans/2026-02-21-humans-and-permissions.md similarity index 100% rename from doc/plan/humans-and-permissions.md rename to doc/plans/2026-02-21-humans-and-permissions.md diff --git a/doc/plans/cursor-cloud-adapter.md b/doc/plans/2026-02-23-cursor-cloud-adapter.md similarity index 100% rename from doc/plans/cursor-cloud-adapter.md rename to doc/plans/2026-02-23-cursor-cloud-adapter.md diff --git a/doc/plans/deployment-auth-mode-consolidation.md b/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md similarity index 100% rename from doc/plans/deployment-auth-mode-consolidation.md rename to doc/plans/2026-02-23-deployment-auth-mode-consolidation.md diff --git a/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md b/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md new file mode 100644 index 00000000..8ab63794 --- /dev/null +++ b/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md @@ -0,0 +1,1335 @@ +# Workspace Strategy and Git Worktrees + +## Context + +`PAP-447` asks how Paperclip should support worktree-driven coding workflows for local coding agents without turning that into a universal product requirement. + +The motivating use case is strong: + +- when an issue starts, a local coding agent may want its own isolated checkout +- the agent may need a dedicated branch and a predictable path to push later +- the agent may need to start one or more long-lived workspace runtime services, discover reachable ports or URLs, and report them back into the issue +- the workflow should reuse the same Paperclip instance and embedded database instead of creating a blank environment +- local agent auth should remain low-friction + +At the same time, we do not want to hard-code "every agent uses git worktrees" into Paperclip: + +- some operators use Paperclip to manage Paperclip and want worktrees heavily +- other operators will not want worktrees at all +- not every adapter runs in a local git repository +- not every adapter runs on the same machine as Paperclip +- Claude and Codex expose different built-in affordances, so Paperclip should not overfit to one tool + +## Core Product Decision + +Paperclip should model **execution workspaces**, not **worktrees**. + +More specifically: + +- the durable anchor is the **project workspace** or repo checkout +- an issue may derive a temporary **execution workspace** from that project workspace +- one implementation of an execution workspace is a **git worktree** +- adapters decide whether and how to use that derived workspace + +This keeps the abstraction portable: + +- `project workspace` is the repo/project-level concept +- `execution workspace` is the runtime checkout/cwd for a run +- `git worktree` is one strategy for creating that execution workspace +- `workspace runtime services` are long-lived processes or previews attached to that workspace + +This also keeps the abstraction valid for non-local adapters: + +- local adapters may receive a real filesystem cwd produced by Paperclip +- remote or cloud adapters may receive the same execution intent in structured form and realize it inside their own environment +- Paperclip should not assume that every adapter can see or use a host filesystem path directly + +## Answer to the Main Framing Questions + +### Are worktrees for agents or for repos/projects? + +They should be treated as **repo/project-scoped infrastructure**, not agent identity. + +The stable object is the project workspace. Agents come and go, ownership changes, and the same issue may be reassigned. A git worktree is a derived checkout of a repo workspace for a specific task or issue. The agent uses it, but should not own the abstraction. + +If Paperclip makes worktrees agent-first, it will blur: + +- agent home directories +- project repo roots +- issue-specific branches/checkouts + +That makes reuse, reassignment, cleanup, and UI visibility harder. + +### How do we preserve optionality? + +By making execution workspace strategy **opt-in at the adapter/config layer**, not a global invariant. + +Defaults should remain: + +- existing project workspace resolution +- existing task-session resume +- existing agent-home fallback + +Then local coding agents can opt into a strategy like `git_worktree`. + +### How do we make this portable and adapter-appropriate? + +By splitting responsibilities: + +- Paperclip core resolves and records execution workspace state +- a shared local runtime helper can implement git-based checkout strategies +- each adapter launches its tool inside the resolved cwd using adapter-specific flags + +This avoids forcing a Claude-shaped or Codex-shaped model onto all adapters. + +It also avoids forcing a host-filesystem model onto cloud agents. A cloud adapter may interpret the same requested strategy as: + +- create a fresh sandbox checkout from repo + ref +- create an isolated branch/workspace inside the provider's remote environment +- ignore local-only fields like host cwd while still honoring branch/ref/isolation intent + +## Product and UX Requirements + +The current technical model is directionally right, but the product surface needs clearer separation between: + +- the generic cross-adapter concept of an **execution workspace** +- the user-visible local-git implementation concept of an **isolated issue checkout** +- the specific git implementation detail of a **git worktree** + +Those should not be collapsed into one label in the UI. + +### Terminology recommendation + +For product/UI copy: + +- use **execution workspace** for the generic cross-adapter concept +- use **isolated issue checkout** for the user-facing feature when we want to say "this issue gets its own branch/checkout" +- reserve **git worktree** for advanced or implementation detail views + +That gives Paperclip room to support: + +- local git worktrees +- remote sandbox checkouts +- adapter-managed remote workspaces + +without teaching users that "workspace" always means "git worktree on my machine". + +### Project-level defaults should drive the feature + +The main place this should be configured is the **project**, not the agent form. + +Reasoning: + +- whether a repo/project wants isolated issue checkouts is primarily a project workflow decision +- most operators do not want to configure runtime JSON per agent +- agents should inherit the project's workspace policy unless there is a strong adapter-specific override +- the board needs a place to express repo workflow defaults such as branching, PRs, cleanup, and preview lifecycle + +So the project should own a setting like: + +- `isolatedIssueCheckouts.enabled` or equivalent + +and that should be the default driver for new issues in that project. + +### Issue-level use should stay optional + +Even when a project supports isolated issue checkouts, not every issue should be forced into one. + +Examples: + +- a small fix may be fine in the main project workspace +- an operator may want to work directly on a long-lived branch +- a board user may want to create a task without paying the setup/cleanup overhead + +So the model should be: + +- project defines whether isolated issue checkouts are available and what the defaults are +- each issue can opt in or out when created +- the default issue value can be inherited from the project + +This should not require showing advanced adapter config in normal issue creation flows. + +### Runtime services should usually be hidden from the agent form + +The current raw runtime service JSON is too low-level as a primary UI for most local agents. + +For `claude_local` and `codex_local`, the likely desired behavior is: + +- Paperclip handles workspace runtime services under the hood using project/workspace policy +- operators do not need to hand-author generic runtime JSON in the agent form +- if a provider-specific adapter later needs richer runtime configuration, give it a purpose-built UI rather than generic JSON by default + +So the UI recommendation is: + +- keep runtime service JSON out of the default local-agent editing experience +- allow it only behind an advanced section or adapter-specific expert mode +- move the common workflow settings up to project-level workspace automation settings + +### Pull request workflow needs explicit ownership and approval rules + +Once Paperclip is creating isolated issue checkouts, it is implicitly touching a bigger workflow: + +- branch creation +- runtime service start/stop +- commit and push +- PR creation +- cleanup after merge or abandonment + +That means the product needs an explicit model for **who owns PR creation and merge readiness**. + +At minimum there are two valid modes: + +- agent-managed PR creation +- approval-gated PR creation + +And likely three distinct decision points: + +1. should the agent commit automatically? +2. should the agent open the PR automatically? +3. does opening or marking-ready require board approval? + +Those should not be buried inside adapter prompts. They are workflow policy. + +### Human operator workflows are different from issue-isolation workflows + +A human operator may want a long-lived personal integration branch such as `dotta` and may not want every task to create a new branch/workspace dance. + +That is a legitimate workflow and should be supported directly. + +So Paperclip should distinguish: + +- **isolated issue checkout workflows**: optimized for agent parallelism and issue-scoped isolation +- **personal branch workflows**: optimized for a human or operator making multiple related changes on a long-lived branch and creating PRs back to the main branch when convenient + +This implies: + +- isolated issue checkouts should be optional even when available +- project workflow settings should support a "use base branch directly" or "use preferred operator branch" path +- PR policy should not assume that every unit of work maps 1:1 to a new branch or PR + +## Recommended UX Model + +### 1. Project-level "Execution Workspace" settings + +Projects should have a dedicated settings area for workspace automation. + +Suggested structure: + +- `Execution Workspaces` + - `Enable isolated issue checkouts` + - `Default for new issues` + - `Checkout implementation` + - `Branch and PR behavior` + - `Runtime services` + - `Cleanup behavior` + +For a local git-backed project, the visible language can be more concrete: + +- `Enable isolated issue checkouts` +- `Implementation: Git worktree` + +For remote or adapter-managed projects, the same section can instead say: + +- `Implementation: Adapter-managed workspace` + +### 2. Issue creation should expose a simple opt-in + +When creating an issue inside a project with execution workspace support enabled: + +- show a checkbox or toggle such as `Use isolated issue checkout` +- default it from the project setting +- hide advanced workspace controls unless the operator has expanded an advanced section + +If the project does not support execution workspaces, do not show the control at all. + +This keeps the default UI light while preserving control. + +### 3. Agent configuration should be mostly inheritance-based + +The agent form should not be the primary place where operators assemble worktree/runtime policy for common local agents. + +Instead: + +- local coding agents inherit the project's execution workspace policy +- the agent form only exposes an override when truly necessary +- raw JSON config is advanced-only + +That means the common case becomes: + +- configure the project once +- assign a local coding agent +- create issues with optional isolated checkout behavior + +### 4. Advanced implementation detail can still exist + +There should still be an advanced view for power users that shows: + +- execution workspace strategy payload +- runtime service intent payload +- adapter-specific overrides + +But that should be treated like an expert/debugging surface, not the default mental model. + +## Recommended Workflow Policy Model + +### Workspace realization policy + +Suggested policy values: + +- `shared_project_workspace` +- `isolated_issue_checkout` +- `adapter_managed_isolated_workspace` + +For local git projects, `isolated_issue_checkout` may map to `git_worktree`. + +### Branch policy + +Suggested project-level branch policy fields: + +- `baseBranch` +- `branchMode`: `issue_scoped | operator_branch | project_primary` +- `branchTemplate` for issue-scoped branches +- `operatorPreferredBranch` for human/operator workflows + +This allows: + +- strict issue branches for agents +- long-lived personal branches for humans +- direct use of the project primary workspace when desired + +### Pull request policy + +Suggested project-level PR policy fields: + +- `prMode`: `none | agent_may_open | agent_auto_open | approval_required` +- `autoPushOnDone`: boolean +- `requireApprovalBeforeOpen`: boolean +- `requireApprovalBeforeReady`: boolean +- `defaultBaseBranch` + +This keeps PR behavior explicit and governable. + +### Cleanup policy + +Suggested project-level cleanup fields: + +- `stopRuntimeServicesOnDone` +- `removeIsolatedCheckoutOnDone` +- `removeIsolatedCheckoutOnMerged` +- `deleteIssueBranchOnMerged` +- `retainFailedWorkspaceForInspection` + +These matter because workspace automation is not just setup. The cleanup path is part of the product. + +## Design Recommendations for the Current UI Problem + +Based on the concerns above, the UI should change in these ways: + +### Agent UI + +- remove generic runtime service JSON from the default local-agent configuration surface +- keep raw workspace/runtime JSON behind advanced settings only +- prefer inheritance from project settings for `claude_local` and `codex_local` +- only add adapter-specific runtime UI when an adapter truly needs settings that Paperclip cannot infer + +### Project UI + +- add a project-level execution workspace settings section +- allow enabling isolated issue checkouts for that project +- store default issue behavior there +- expose branch, PR, runtime service, and cleanup defaults there + +### Issue creation UI + +- only show `Use isolated issue checkout` when the project has execution workspace support enabled +- keep it as an issue-level opt-in/out, defaulted from the project +- hide advanced execution workspace details unless requested + +## Consequences for the Spec + +This changes the emphasis of the plan in a useful way: + +- the project becomes the main workflow configuration owner +- the issue becomes the unit of opt-in/out for isolated checkout behavior +- the agent becomes an executor that usually inherits the workflow policy +- raw runtime JSON becomes an advanced/internal representation, not the main UX + +It also clarifies that PR creation and cleanup are not optional side notes. They are core parts of the workspace automation product surface. + +## Concrete Integration Checklist + +This section turns the product requirements above into a concrete implementation plan for the current codebase. + +### Guiding precedence rule + +The runtime decision order should become: + +1. issue-level execution workspace override +2. project-level execution workspace policy +3. agent-level adapter override +4. current default behavior + +That is the key architectural change. Today the implementation is too agent-config-centered for the desired UX. + +## Proposed Field Names + +### Project-level fields + +Add a project-owned execution workspace policy object. Suggested shared shape: + +```ts +type ProjectExecutionWorkspacePolicy = { + enabled: boolean; + defaultMode: "inherit_project_default" | "shared_project_workspace" | "isolated_issue_checkout"; + implementation: "git_worktree" | "adapter_managed"; + branchPolicy: { + baseBranch: string | null; + branchMode: "issue_scoped" | "operator_branch" | "project_primary"; + branchTemplate: string | null; + operatorPreferredBranch: string | null; + }; + pullRequestPolicy: { + mode: "none" | "agent_may_open" | "agent_auto_open" | "approval_required"; + autoPushOnDone: boolean; + requireApprovalBeforeOpen: boolean; + requireApprovalBeforeReady: boolean; + defaultBaseBranch: string | null; + }; + cleanupPolicy: { + stopRuntimeServicesOnDone: boolean; + removeExecutionWorkspaceOnDone: boolean; + removeExecutionWorkspaceOnMerged: boolean; + deleteIssueBranchOnMerged: boolean; + retainFailedWorkspaceForInspection: boolean; + }; + runtimeServices: { + mode: "disabled" | "project_default"; + services?: Array>; + }; +}; +``` + +Notes: + +- `enabled` controls whether the project exposes isolated issue checkout behavior at all +- `defaultMode` controls issue creation defaults +- `implementation` stays generic enough for local or remote adapters +- runtime service config stays nested here, not in the default agent form + +### Issue-level fields + +Add issue-owned opt-in/override fields. Suggested shape: + +```ts +type IssueExecutionWorkspaceSettings = { + mode?: "inherit_project_default" | "shared_project_workspace" | "isolated_issue_checkout"; + branchOverride?: string | null; + pullRequestModeOverride?: "inherit" | "none" | "agent_may_open" | "agent_auto_open" | "approval_required"; +}; +``` + +This should usually be hidden behind simple UI: + +- a checkbox like `Use isolated issue checkout` +- advanced controls only when needed + +### Agent-level fields + +Keep agent-level workspace/runtime configuration, but reposition it as advanced override only. + +Suggested semantics: + +- if absent, inherit project + issue policy +- if present, override only the implementation detail needed for that adapter + +## Shared Type and API Changes + +### 1. Shared project types + +Files to change first: + +- `packages/shared/src/types/project.ts` +- `packages/shared/src/validators/project.ts` + +Add: + +- `executionWorkspacePolicy?: ProjectExecutionWorkspacePolicy | null` + +### 2. Shared issue types + +Files to change: + +- `packages/shared/src/types/issue.ts` +- `packages/shared/src/validators/issue.ts` + +Add: + +- `executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null` + +### 3. DB schema + +If we want these fields persisted directly on existing entities instead of living in opaque JSON: + +- `packages/db/src/schema/projects.ts` +- `packages/db/src/schema/issues.ts` +- migration generation in `packages/db/src/migrations/` + +Recommended first cut: + +- store project policy as JSONB on `projects` +- store issue setting override as JSONB on `issues` + +That minimizes schema churn while the product model is still moving. + +Suggested columns: + +- `projects.execution_workspace_policy jsonb` +- `issues.execution_workspace_settings jsonb` + +## Server-Side Resolution Changes + +### 4. Project service read/write path + +Files: + +- `server/src/services/projects.ts` +- project routes in `server/src/routes/projects.ts` + +Tasks: + +- accept and validate project execution workspace policy +- return it from project API payloads +- enforce company scoping as usual + +### 5. Issue service create/update path + +Files: + +- `server/src/services/issues.ts` +- `server/src/routes/issues.ts` + +Tasks: + +- accept issue-level `executionWorkspaceSettings` +- when creating an issue in a project with execution workspaces enabled, default the issue setting from the project policy if not explicitly provided +- keep issue payload simple for normal clients; advanced fields may be optional + +### 6. Heartbeat and run resolution + +Primary file: + +- `server/src/services/heartbeat.ts` + +Current behavior should be refactored so workspace resolution is based on: + +- issue setting +- then project policy +- then adapter override + +Specific technical work: + +- load project execution workspace policy during run resolution +- load issue execution workspace settings during run resolution +- derive an effective execution workspace decision object before adapter launch +- keep adapter config as override only + +Suggested internal helper: + +```ts +type EffectiveExecutionWorkspaceDecision = { + mode: "shared_project_workspace" | "isolated_issue_checkout"; + implementation: "git_worktree" | "adapter_managed" | "project_primary"; + branchPolicy: {...}; + pullRequestPolicy: {...}; + cleanupPolicy: {...}; + runtimeServices: {...}; +}; +``` + +## UI Changes + +### 7. Project settings UI + +Likely files: + +- `ui/src/components/ProjectProperties.tsx` +- project detail/settings pages under `ui/src/pages/` +- project API client in `ui/src/api/projects.ts` + +Add a project-owned section: + +- `Execution Workspaces` + - enable isolated issue checkouts + - default for new issues + - implementation type + - branch settings + - PR settings + - cleanup settings + - runtime service defaults + +Important UX rule: + +- runtime service config should not default to raw JSON +- if the first cut must use JSON internally, wrap it in a minimal structured form or advanced disclosure + +### 8. Issue creation/edit UI + +Likely files: + +- issue create UI components and issue detail edit surfaces in `ui/src/pages/` +- issue API client in `ui/src/api/issues.ts` + +Add: + +- `Use isolated issue checkout` toggle, only when project policy enables it +- advanced workspace behavior controls only when expanded + +Do not show: + +- raw runtime service JSON +- raw strategy payloads + +in the default issue creation flow. + +### 9. Agent UI cleanup + +Files: + +- `ui/src/adapters/local-workspace-runtime-fields.tsx` +- `ui/src/adapters/codex-local/config-fields.tsx` +- `ui/src/adapters/claude-local/config-fields.tsx` + +Technical direction: + +- keep the existing config surface as advanced override +- remove it from the default form flow for local coding agents +- add explanatory copy that project execution workspace policy is inherited unless overridden + +## Adapter and Orchestration Changes + +### 10. Local adapter behavior + +Files: + +- `packages/adapters/codex-local/src/ui/build-config.ts` +- `packages/adapters/claude-local/src/ui/build-config.ts` +- local adapter execute paths already consuming env/context + +Tasks: + +- continue to accept resolved workspace/runtime context from heartbeat +- stop assuming the agent config is the primary source of workspace policy +- preserve adapter-specific override support + +### 11. Runtime service orchestration + +Files: + +- `server/src/services/workspace-runtime.ts` + +Tasks: + +- accept runtime service defaults from the effective project/issue policy +- keep adapter-config runtime service JSON as override-only +- preserve portability for remote adapters + +## Pull Request and Cleanup Workflow + +### 12. PR policy execution + +This is not fully implemented today and should be treated as a separate orchestration layer. + +Likely files: + +- `server/src/services/heartbeat.ts` +- future git/provider integration helpers + +Needed decisions: + +- when issue moves to done, should Paperclip auto-commit? +- should it auto-push? +- should it auto-open a PR? +- should PR open/ready be approval-gated? + +Suggested approach: + +- store PR policy on project +- resolve effective PR policy per issue/run +- emit explicit workflow actions rather than relying on prompt text alone + +### 13. Cleanup policy execution + +Likely files: + +- `server/src/services/workspace-runtime.ts` +- `server/src/services/heartbeat.ts` +- any future merge-detection hooks + +Needed behaviors: + +- stop runtime services on done or merged +- remove isolated checkout on done or merged +- delete branch on merged if policy says so +- optionally retain failed workspace for inspection + +## Recommended First Implementation Sequence + +To integrate these ideas without destabilizing the system, implement in this order: + +1. Add project policy fields to shared types, validators, DB, services, routes, and project UI. +2. Add issue-level execution workspace setting fields to shared types, validators, DB, services, routes, and issue create/edit UI. +3. Refactor heartbeat to compute effective execution workspace policy from issue -> project -> agent override. +4. Change local-agent UI so workspace/runtime JSON becomes advanced-only. +5. Move default runtime service behavior to project settings. +6. Add explicit PR policy storage and resolution. +7. Add explicit cleanup policy storage and resolution. + +## Definition of Done for This Product Shift + +This design shift is complete when all are true: + +- project settings own the default workspace policy +- issue creation exposes a simple opt-in/out when available +- local agent forms no longer require raw runtime JSON for common cases +- heartbeat resolves effective workspace behavior from project + issue + override precedence +- PR and cleanup behavior are modeled as explicit policy, not implied prompt behavior +- the UI language distinguishes execution workspace from local git worktree implementation details + +## What the Current Code Already Supports + +Paperclip already has the right foundation for a project-first model. + +### Project workspace is already first-class + +- `project_workspaces` already exists in `packages/db/src/schema/project_workspaces.ts` +- the shared `ProjectWorkspace` type already includes `cwd`, `repoUrl`, and `repoRef` in `packages/shared/src/types/project.ts` +- docs already state that agents use the project's primary workspace for project-scoped tasks in `docs/api/goals-and-projects.md` + +### Heartbeat already resolves workspace in the right order + +Current run resolution already prefers: + +1. project workspace +2. prior task session cwd +3. agent-home fallback + +See `server/src/services/heartbeat.ts`. + +### Session resume is already cwd-aware + +Both local coding adapters treat session continuity as cwd-bound: + +- Codex: `packages/adapters/codex-local/src/server/execute.ts` +- Claude: `packages/adapters/claude-local/src/server/execute.ts` + +That means the clean insertion point is before adapter execution: resolve the final execution cwd first, then let the adapter run normally. + +### Server-spawned local auth already exists + +For server-spawned local adapters, Paperclip already injects a short-lived local JWT: + +- JWT creation: `server/src/services/heartbeat.ts` +- adapter env injection: + - `packages/adapters/codex-local/src/server/execute.ts` + - `packages/adapters/claude-local/src/server/execute.ts` + +The manual-local bootstrap path is still weaker in authenticated mode, but that is a related auth ergonomics problem, not a reason to make worktrees a core invariant. + +## Tooling Observations from Vendor Docs + +The linked tool docs support a project-first, adapter-specific launch model. + +### Codex + +- Codex app has a native worktree concept for parallel tasks in git repos +- Codex CLI documents running in a chosen working directory and resuming sessions from the current working directory +- Codex CLI does not present a single first-class portable CLI worktree abstraction that Paperclip should mirror directly + +Implication: + +- for `codex_local`, Paperclip should usually create/select the checkout itself and then launch Codex inside that cwd + +### Claude + +- Claude documents explicit git worktree workflows for parallel sessions +- Claude CLI supports `--worktree` / `-w` +- Claude sessions also remain tied to directory context + +Implication: + +- `claude_local` can optionally use native `--worktree` +- but Paperclip should still treat that as an adapter optimization, not the canonical cross-adapter model + +## Local vs Remote Adapters + +This plan must explicitly account for the fact that many adapters are not local. + +Examples: + +- local CLI adapters such as `codex_local` and `claude_local` +- cloud-hosted coding agents such as Cursor cloud agents +- future hosted Codex or Claude agent modes +- custom sandbox adapters built on E2B, Cloudflare, or similar environments + +These adapters do not all share the same capabilities: + +- some can use host git worktrees directly +- some can clone a repo and create branches remotely +- some may expose a virtual workspace concept with no direct git worktree equivalent +- some may not allow persistent filesystem state at all + +Because of that, Paperclip should separate: + +- **execution workspace intent**: what isolation/branch/repo behavior we want +- **adapter realization**: how a specific adapter implements that behavior + +### Execution workspace intent + +Paperclip should be able to express intentions such as: + +- use the project's primary workspace directly +- create an isolated issue-scoped checkout +- base work on a given repo ref +- derive a branch name from the issue +- expose one or more reachable preview or service URLs if runtime services are started + +### Adapter realization + +Adapters should be free to map that intent into their own environment: + +- local adapter: create a host git worktree and run in that cwd +- cloud sandbox adapter: clone repo into a sandbox, create a branch there, and return sandbox metadata +- hosted remote coding agent: call provider APIs that create a remote workspace/thread bound to the requested branch/ref + +The important constraint is that the adapter reports back the realized execution workspace metadata in a normalized shape, even if the underlying implementation is not a git worktree. + +## Proposed Model + +Use three layers: + +1. `project workspace` +2. `execution workspace` +3. `workspace runtime services` +4. `adapter session` + +### 1. Project workspace + +Long-lived repo anchor. + +Examples: + +- `./paperclip` +- repo URL and base ref +- primary checkout for a project + +### 2. Execution workspace + +Derived runtime checkout for a specific issue/run. + +Examples: + +- direct use of the project primary workspace +- git worktree derived from the project workspace +- remote sandbox checkout derived from repo URL + ref +- custom checkout produced by an adapter-specific script + +### 3. Adapter session + +Long-lived or semi-long-lived processes associated with a workspace. + +Examples: + +- local web server +- background worker +- sandbox preview URL +- test watcher +- tunnel process + +These are not specific to Paperclip. They are a common property of working in a dev workspace, whether local or remote. + +### 4. Adapter session + +Claude/Codex conversation continuity and runtime state, which remains cwd-aware and should follow the execution workspace rather than define it. + +## Recommended Configuration Surface + +Introduce a generic execution workspace strategy in adapter config. + +Example shape: + +```json +{ + "workspaceStrategy": { + "type": "project_primary" + } +} +``` + +Or: + +```json +{ + "workspaceStrategy": { + "type": "git_worktree", + "baseRef": "origin/main", + "branchTemplate": "{{issue.identifier}}-{{slug}}", + "worktreeParentDir": ".paperclip/instances/default/worktrees/projects/{{project.id}}", + "cleanupPolicy": "on_merged", + "startDevServer": true, + "devServerCommand": "pnpm dev", + "devServerReadyUrlTemplate": "http://127.0.0.1:{{port}}/api/health" + } +} +``` + +Remote adapters may instead use shapes like: + +```json +{ + "workspaceStrategy": { + "type": "isolated_checkout", + "provider": "adapter_managed", + "baseRef": "origin/main", + "branchTemplate": "{{issue.identifier}}-{{slug}}" + } +} +``` + +The important point is that `git_worktree` is a strategy value for adapters that can use it, not the universal contract. + +### Workspace runtime services + +Do not model this as a Paperclip-specific `devServer` flag. + +Instead, model it as a generic list of workspace-attached runtime services. + +Example shape: + +```json +{ + "workspaceRuntime": { + "services": [ + { + "name": "web", + "description": "Primary app server for this workspace", + "command": "pnpm dev", + "cwd": ".", + "env": { + "DATABASE_URL": "${workspace.env.DATABASE_URL}" + }, + "port": { + "type": "auto" + }, + "readiness": { + "type": "http", + "urlTemplate": "http://127.0.0.1:${port}/api/health" + }, + "expose": { + "type": "url", + "urlTemplate": "http://127.0.0.1:${port}" + }, + "reuseScope": "project_workspace", + "lifecycle": "shared", + "stopPolicy": { + "type": "idle_timeout", + "idleSeconds": 1800 + } + } + ] + } +} +``` + +This contract is intentionally generic: + +- `command` can start any workspace-attached process, not just a web server +- database reuse is handled through env/config injection, not a product-specific special case +- local and remote adapters can realize the same service intent differently + +### Service intent vs service realization + +Paperclip should distinguish between: + +- **service intent**: what kind of companion runtime the workspace wants +- **service realization**: how a local or remote adapter actually starts and exposes it + +Examples: + +- local adapter: + - starts `pnpm dev` + - allocates a free host port + - health-checks a localhost URL + - reports `{ pid, port, url }` +- cloud sandbox adapter: + - starts a preview process inside the sandbox + - receives a provider preview URL + - reports `{ sandboxId, previewUrl }` +- hosted remote coding agent: + - may ask the provider to create a preview environment + - reports provider-native workspace/service metadata + +Paperclip should normalize the reported metadata without requiring every adapter to look like a host-local process. + +Keep issue-level overrides possible through the existing `assigneeAdapterOverrides` shape in `packages/shared/src/types/issue.ts`. + +## Responsibilities by Layer + +### Paperclip Core + +Paperclip core should: + +- resolve the base project workspace for the issue +- resolve or request an execution workspace +- resolve or request workspace runtime services when configured +- inject execution workspace metadata into run context +- persist enough metadata for board visibility and cleanup +- manage lifecycle hooks around run start/finish where needed + +Paperclip core should not: + +- require worktrees for all agents +- assume every adapter is local and git-backed +- assume every runtime service is a localhost process with a PID +- encode tool-specific worktree prompts as core product behavior + +### Shared Local Runtime Helper + +A shared server-side helper should handle local git mechanics: + +- validate repo root +- create/select branch +- create/select git worktree +- allocate a free port +- optionally start and track a dev server +- return `{ cwd, branchName, url }` + +This helper can be reused by: + +- `codex_local` +- `claude_local` +- future local adapters like Cursor/OpenCode equivalents + +This helper is intentionally for local adapters only. Remote adapters should not be forced through a host-local git helper. + +### Shared Runtime Service Manager + +In addition to the local git helper, Paperclip should define a generic runtime service manager contract. + +Its job is to: + +- decide whether a configured service should be reused or started fresh +- allocate local ports when needed +- start and monitor local processes when the adapter/runtime realization is host-local +- record normalized service metadata for remote realizations +- run readiness checks +- surface service URLs and state to the board +- apply shutdown policy + +This manager should not be hard-coded to "dev servers". It should work for any long-lived workspace companion process. + +### Adapter + +The adapter should: + +- accept the resolved execution cwd +- or accept structured execution workspace intent when no host cwd is available +- accept structured workspace runtime service intent when service orchestration is delegated to the adapter +- launch its tool with adapter-specific flags +- keep its own session continuity semantics + +For example: + +- `codex_local`: run inside cwd, likely with `--cd` or process cwd +- `claude_local`: run inside cwd, optionally use `--worktree` when it helps +- remote sandbox adapter: create its own isolated workspace from repo/ref/branch intent and report the realized remote workspace metadata back to Paperclip + +For runtime services: + +- local adapter or shared host manager: start the local process and return host-local metadata +- remote adapter: create or reuse the remote preview/service and return normalized remote metadata + +## Minimal Data Model Additions + +Do not create a fully first-class `worktrees` table yet. + +Start smaller by recording derived execution workspace metadata on runs, issues, or both. + +Suggested fields to introduce: + +- `executionWorkspaceStrategy` +- `executionWorkspaceCwd` +- `executionBranchName` +- `executionWorkspaceStatus` +- `executionServiceRefs` +- `executionCleanupStatus` + +These can live first on `heartbeat_runs.context_snapshot` or adjacent run metadata, with an optional later move into a dedicated table if the UI and cleanup workflows justify it. + +For runtime services specifically, Paperclip should eventually track normalized fields such as: + +- `serviceName` +- `serviceKind` +- `scopeType` +- `scopeId` +- `status` +- `command` +- `cwd` +- `envFingerprint` +- `port` +- `url` +- `provider` +- `providerRef` +- `startedByRunId` +- `ownerAgentId` +- `lastUsedAt` +- `stopPolicy` +- `healthStatus` + +The first implementation can keep this in run metadata if needed, but the long-term shape is a generic runtime service registry rather than one-off server URL fields. + +## Concrete Implementation Plan + +## Phase 1: Define Shared Contracts + +1. Introduce a shared execution workspace strategy contract in `packages/shared`. +2. Add adapter-config schema support for: + - `workspaceStrategy.type` + - `baseRef` + - `branchTemplate` + - `worktreeParentDir` + - `cleanupPolicy` + - optional workspace runtime service settings +3. Keep the existing `useProjectWorkspace` flag working as a lower-level compatibility control. +4. Distinguish local realization fields from generic intent fields so remote adapters are not forced to consume host cwd values. +5. Define a generic `workspaceRuntime.services[]` contract with: + - service name + - command or provider-managed intent + - env overrides + - readiness checks + - exposure metadata + - reuse scope + - lifecycle + - stop policy + +Acceptance: + +- adapter config can express `project_primary` and `git_worktree` +- config remains optional and backwards-compatible +- runtime services are expressed generically, not as Paperclip-only dev-server flags + +## Phase 2: Resolve Execution Workspace in Heartbeat + +1. Extend heartbeat workspace resolution so it can return a richer execution workspace result. +2. Keep current fallback order, but distinguish: + - base project workspace + - derived execution workspace +3. Inject resolved execution workspace details into `context.paperclipWorkspace` for local adapters and into a generic execution-workspace intent payload for adapters that need structured remote realization. +4. Resolve configured runtime service intent alongside the execution workspace so the adapter or host manager receives a complete workspace runtime contract. + +Primary touchpoints: + +- `server/src/services/heartbeat.ts` + +Acceptance: + +- runs still work unchanged when no strategy is configured +- the resolved context clearly indicates which strategy produced the cwd + +## Phase 3: Add Shared Local Git Workspace Helper + +1. Create a server-side helper module for local repo checkout strategies. +2. Implement `git_worktree` strategy: + - validate git repo at base workspace cwd + - derive branch name from issue + - create or reuse a worktree path + - detect collisions cleanly +3. Return structured metadata: + - final cwd + - branch name + - worktree path + - repo root + +Acceptance: + +- helper is reusable outside a single adapter +- worktree creation is deterministic for a given issue/config +- remote adapters remain unaffected by this helper + +## Phase 4: Optional Dev Server Lifecycle + +Rename this phase conceptually to **workspace runtime service lifecycle**. + +1. Add optional runtime service startup on execution workspace creation. +2. Support both: + - host-managed local services + - adapter-managed remote services +3. For local services: + - allocate a free port before launch when required + - start the configured command in the correct cwd + - run readiness checks + - register the realized metadata +4. For remote services: + - let the adapter return normalized service metadata after provisioning + - do not assume PID or localhost access +5. Post or update issue-visible metadata with the service URLs and labels. + +Acceptance: + +- runtime service startup remains opt-in +- failures produce actionable run logs and issue comments +- same embedded DB / Paperclip instance can be reused through env/config injection when appropriate +- remote service realizations are represented without pretending to be local processes + +## Phase 5: Runtime Service Reuse, Tracking, and Shutdown + +1. Introduce a generic runtime service registry. +2. Each service should be tracked with: + - `scopeType`: `project_workspace | execution_workspace | run | agent` + - `scopeId` + - `serviceName` + - `status` + - `command` or provider metadata + - `cwd` if local + - `envFingerprint` + - `port` + - `url` + - `provider` / `providerRef` + - `ownerAgentId` + - `startedByRunId` + - `lastUsedAt` + - `stopPolicy` +3. Introduce a deterministic `reuseKey`, for example: + - `projectWorkspaceId + serviceName + envFingerprint` +4. Reuse policy: + - if a healthy service with the same reuse key exists, attach to it + - otherwise start a new service +5. Distinguish lifecycle classes: + - `shared`: reusable across runs, usually scoped to `project_workspace` + - `ephemeral`: tied to `execution_workspace` or `run` +6. Shutdown policy: + - `run` scope: stop when run ends + - `execution_workspace` scope: stop when workspace is cleaned up + - `project_workspace` scope: stop on idle timeout, explicit stop, or workspace removal + - `agent` scope: stop when ownership is transferred or agent policy requires it +7. Health policy: + - readiness check at startup + - periodic or on-demand liveness checks + - mark unhealthy before killing when possible + +Acceptance: + +- Paperclip can decide whether to reuse or start a fresh service deterministically +- local and remote services share a normalized tracking model +- shutdown is policy-driven instead of implicit +- board can understand why a service was kept, reused, or stopped + +## Phase 6: Adapter Integration + +1. Update `codex_local` to consume resolved execution workspace cwd. +2. Update `claude_local` to consume resolved execution workspace cwd. +3. Define a normalized adapter contract for remote adapters that receive execution workspace intent instead of a host-local cwd. +4. Optionally allow Claude-specific optimization paths using native `--worktree`, but keep the shared server-side checkout strategy as canonical for local adapters. +5. Define how adapters return runtime service realizations: + - local host-managed service reference + - remote provider-managed service reference + +Acceptance: + +- adapter behavior remains unchanged when strategy is absent +- session resume remains cwd-safe +- no adapter is forced into git behavior +- remote adapters can implement equivalent isolation without pretending to be local worktrees +- adapters can report service URLs and lifecycle metadata in a normalized shape + +## Phase 7: Visibility and Issue Comments + +1. Expose execution workspace metadata in run details and optionally issue detail UI: + - strategy + - cwd + - branch + - runtime service refs +2. Expose runtime services with: + - service name + - status + - URL + - scope + - owner + - health +3. Add standard issue comment output when a worktree-backed or remotely isolated run starts: + - branch + - worktree path + - service URLs if present + +Acceptance: + +- board can see where the agent is working +- board can see what runtime services exist for that workspace +- issue thread becomes the handoff surface for branch names and reachable URLs + +## Phase 8: Cleanup Policies + +1. Implement cleanup policies: + - `manual` + - `on_done` + - `on_merged` +2. For worktree cleanup: + - stop tracked runtime services if owned by the workspace lifecycle + - remove worktree + - optionally delete local branch after merge +3. Start with conservative defaults: + - do not auto-delete anything unless explicitly configured + +Acceptance: + +- cleanup is safe and reversible by default +- merge-based cleanup can be introduced after basic lifecycle is stable + +## Phase 9: Auth Ergonomics Follow-Up + +This is related, but should be tracked separately from the workspace strategy work. + +Needed improvement: + +- make manual local agent bootstrap in authenticated/private mode easier, so operators can become `codexcoder` or `claudecoder` locally without depending on an already-established browser-auth CLI context + +This should likely take the form of a local operator bootstrap flow, not a weakening of runtime auth boundaries. + +## Rollout Strategy + +1. Ship the shared config contract and no-op-compatible heartbeat changes first. +2. Pilot with `codexcoder` and `claudecoder` only. +3. Test against Paperclip-on-Paperclip workflows first. +4. Keep `project_primary` as the default for all existing agents. +5. Add UI exposure and cleanup only after the core runtime path is stable. + +## Acceptance Criteria + +1. Worktree behavior is optional, not a global requirement. +2. Project workspaces remain the canonical repo anchor. +3. Local coding agents can opt into isolated issue-scoped execution workspaces. +4. The same model works for both `codex_local` and `claude_local` without forcing a tool-specific abstraction into core. +5. Remote adapters can consume the same execution workspace intent without requiring host-local filesystem access. +6. Session continuity remains correct because each adapter resumes relative to its realized execution workspace. +7. Workspace runtime services are modeled generically, not as Paperclip-specific dev-server toggles. +8. Board users can see branch/path/URL information for worktree-backed or remotely isolated runs. +9. Service reuse and shutdown are deterministic and policy-driven. +10. Cleanup is conservative by default. + +## Recommended Initial Scope + +To keep this tractable, the first implementation should: + +- support only local coding adapters +- support only `project_primary` and `git_worktree` +- avoid a new dedicated database table for worktrees +- start with a single host-managed runtime service implementation path +- postpone merge-driven cleanup automation until after basic start/run/visibility is proven + +That is enough to validate the local product shape without prematurely freezing the wrong abstraction. + +Follow-up expansion after that validation: + +- define the remote adapter contract for adapter-managed isolated checkouts +- add one cloud/sandbox adapter implementation path +- normalize realized metadata so local and remote execution workspaces appear similarly in the UI +- expand the runtime service registry from local host-managed services to remote adapter-managed services diff --git a/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md b/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md new file mode 100644 index 00000000..7364b6d0 --- /dev/null +++ b/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md @@ -0,0 +1,329 @@ +# Agent Chat UI and Issue-Backed Conversations + +## Context + +`PAP-475` asks two related questions: + +1. What UI kit should Paperclip use if we add a chat surface with an agent? +2. How should chat fit the product without breaking the current issue-centric model? + +This is not only a component-library decision. In Paperclip today: + +- V1 explicitly says communication is `tasks + comments only`, with no separate chat system. +- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage. +- Live run streaming already exists on issue detail pages. +- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`. +- The OpenClaw gateway adapter already supports an issue-scoped session key strategy. + +That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have." + +## Current Constraints From the Codebase + +### Durable work object + +The durable object in Paperclip is the issue, not a chat thread. + +- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline. +- `CommentThread` already renders markdown comments and supports reply/reassignment flows. +- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs. + +### Session behavior + +Session continuity is already task-shaped. + +- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`. +- `agent_task_sessions` stores session state per company + agent + adapter + task key. +- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well. + +That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system. + +### Billing behavior + +Billing is already issue-aware. + +- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`. +- heartbeat context already propagates issue linkage into runs and cost rollups. + +If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable. + +## UI Kit Recommendation + +## Recommendation: `assistant-ui` + +Use `assistant-ui` as the chat presentation layer. + +Why it fits Paperclip: + +- It is a real chat UI kit, not just a hook. +- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well. +- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls. +- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering. + +Why not make "the Vercel one" the primary choice: + +- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols. +- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip. +- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model. + +So the clean split is: + +- `assistant-ui` for UI primitives +- Paperclip-owned runtime/store for state, persistence, and transport +- optional AI SDK usage later only if we want its stream protocol or client transport abstraction + +## Product Options + +### Option A: Separate chat object + +Create a new top-level chat/thread model unrelated to issues. + +Pros: + +- clean mental model if users want freeform conversation +- easy to hide from issue boards + +Cons: + +- breaks the current V1 product decision that communication is issue-centric +- needs new persistence, billing, session, permissions, activity, and wakeup rules +- creates a second "why does this exist?" object beside issues +- makes "pick up an old chat" a separate retrieval problem + +Verdict: not recommended for V1. + +### Option B: Every chat is an issue + +Treat chat as a UI mode over an issue. The issue remains the durable record. + +Pros: + +- matches current product spec +- billing, runs, comments, approvals, and activity already work +- sessions already resume on issue identity +- works with all adapters, including OpenClaw, without new agent auth or a second API surface + +Cons: + +- some chats are not really "tasks" in a board sense +- onboarding and review conversations may clutter normal issue lists + +Verdict: best V1 foundation. + +### Option C: Hybrid with hidden conversation issues + +Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted. + +Pros: + +- preserves the issue-centric backend +- gives onboarding/review chat a cleaner UX +- preserves billing and session continuity + +Cons: + +- requires extra UI rules and possibly a small schema or filtering addition +- can become a disguised second system if not kept narrow + +Verdict: likely the right product shape after a basic issue-backed MVP. + +## Recommended Product Model + +### Phase 1 product decision + +For the first implementation, chat should be issue-backed. + +More specifically: + +- the board opens a chat surface for an issue +- sending a message is a comment mutation on that issue +- the assigned agent is woken through the existing issue-comment flow +- streaming output comes from the existing live run stream for that issue +- durable assistant output remains comments and run history, not an extra transcript store + +This keeps Paperclip honest about what it is: + +- the control plane stays issue-centric +- chat is a better way to interact with issue work, not a new collaboration product + +### Onboarding and CEO conversations + +For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab. + +Suggested shape: + +- create a board-initiated issue assigned to the CEO +- mark it as conversation-flavored in UI treatment +- optionally hide it from normal issue boards by default later +- keep all cost/run/session linkage on that issue + +This solves several concerns at once: + +- no separate API key or direct provider wiring is needed +- the same CEO adapter is used +- old conversations are recovered through normal issue history +- the CEO can still create or update real child issues from the conversation + +## Session Model + +### V1 + +Use one durable conversation session per issue. + +That already matches current behavior: + +- adapter task sessions persist against `taskKey` +- `taskKey` already falls back to `issueId` +- OpenClaw already supports an issue-scoped session key + +This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue. + +### What not to add yet + +Do not add multi-thread-per-issue chat in the first pass. + +If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive: + +- `taskKey = issue::conversation:` +- OpenClaw `sessionKey = paperclip:conversation:` + +Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule. + +## Billing Model + +Chat should not invent a separate billing pipeline. + +All chat cost should continue to roll up through the issue: + +- `cost_events.issueId` +- project and goal rollups through existing relationships +- issue `billingCode` when present + +If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail. + +This is another reason ephemeral freeform chat should not be the default. + +## UI Architecture + +### Recommended stack + +1. Keep Paperclip as the source of truth for message history and run state. +2. Add `assistant-ui` as the rendering/composer layer. +3. Build a Paperclip runtime adapter that maps: + - issue comments -> user/assistant messages + - live run deltas -> streaming assistant messages + - issue attachments -> chat attachments +4. Keep current markdown rendering and code-block support where possible. + +### Interaction flow + +1. Board opens issue detail in "Chat" mode. +2. Existing comment history is mapped into chat messages. +3. When the board sends a message: + - `POST /api/issues/{id}/comments` + - optionally interrupt the active run if the UX wants "send and replace current response" +4. Existing issue comment wakeup logic wakes the assignee. +5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming. +6. When the run completes, durable state remains in comments/runs/activity as it does now. + +### Why this fits the current code + +Paperclip already has most of the backend pieces: + +- issue comments +- run timeline +- run log and event streaming +- markdown rendering +- attachment support +- assignee wakeups on comments + +The missing piece is mostly the presentation and the mapping layer, not a new backend domain. + +## Agent Scope + +Do not launch this as "chat with every agent." + +Start narrower: + +- onboarding chat with CEO +- workflow/review chat with CEO +- maybe selected exec roles later + +Reasons: + +- it keeps the feature from becoming a second inbox/chat product +- it limits permission and UX questions early +- it matches the stated product demand + +If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly. + +## Recommended Delivery Phases + +### Phase 1: Chat UI on existing issues + +- add a chat presentation mode to issue detail +- use `assistant-ui` +- map comments + live runs into the chat surface +- no schema change +- no new API surface + +This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion. + +### Phase 2: Conversation-flavored issues for CEO chat + +- add a lightweight conversation classification +- support creation of CEO conversation issues from onboarding and workflow entry points +- optionally hide these from normal backlog/board views by default + +The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later. + +### Phase 3: Promotion and thread splitting only if needed + +Only if we later see a real need: + +- allow promoting a conversation to a formal task issue +- allow several threads per issue with explicit conversation identity + +This should be demand-driven, not designed up front. + +## Clear Recommendation + +If the question is "what should we use?", the answer is: + +- use `assistant-ui` for the chat UI +- do not treat raw Vercel AI SDK UI hooks as the main product answer +- keep chat issue-backed in V1 +- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem + +If the question is "how should we think about chat in Paperclip?", the answer is: + +- chat is a mode of interacting with issue-backed agent work +- not a separate product silo +- not an excuse to stop tracing work, cost, and session history back to the issue + +## Implementation Notes + +### Immediate implementation target + +The most defensible first build is: + +- add a chat tab or chat-focused layout on issue detail +- back it with the currently assigned agent on that issue +- use `assistant-ui` primitives over existing comments and live run events + +### Defer these until proven necessary + +- standalone global chat objects +- multi-thread chat inside one issue +- chat with every agent in the org +- a second persistence layer for message history +- separate cost tracking for chats + +## References + +- V1 communication model: `doc/SPEC-implementation.md` +- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx` +- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts` +- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md` +- assistant-ui docs: +- assistant-ui repo: +- AI SDK transport docs: diff --git a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md new file mode 100644 index 00000000..7053e97f --- /dev/null +++ b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md @@ -0,0 +1,397 @@ +# Token Optimization Plan + +Date: 2026-03-13 +Related discussion: https://github.com/paperclipai/paperclip/discussions/449 + +## Goal + +Reduce token consumption materially without reducing agent capability, control-plane visibility, or task completion quality. + +This plan is based on: + +- the current V1 control-plane design +- the current adapter and heartbeat implementation +- the linked user discussion +- local runtime data from the default Paperclip instance on 2026-03-13 + +## Executive Summary + +The discussion is directionally right about two things: + +1. We should preserve session and prompt-cache locality more aggressively. +2. We should separate stable startup instructions from per-heartbeat dynamic context. + +But that is not enough on its own. + +After reviewing the code and local run data, the token problem appears to have four distinct causes: + +1. **Measurement inflation on sessioned adapters.** Some token counters, especially for `codex_local`, appear to be recorded as cumulative session totals instead of per-heartbeat deltas. +2. **Avoidable session resets.** Task sessions are intentionally reset on timer wakes and manual wakes, which destroys cache locality for common heartbeat paths. +3. **Repeated context reacquisition.** The `paperclip` skill tells agents to re-fetch assignments, issue details, ancestors, and full comment threads on every heartbeat. The API does not currently offer efficient delta-oriented alternatives. +4. **Large static instruction surfaces.** Agent instruction files and globally injected skills are reintroduced at startup even when most of that content is unchanged and not needed for the current task. + +The correct approach is: + +1. fix telemetry so we can trust the numbers +2. preserve reuse where it is safe +3. make context retrieval incremental +4. add session compaction/rotation so long-lived sessions do not become progressively more expensive + +## Validated Findings + +### 1. Token telemetry is at least partly overstated today + +Observed from the local default instance: + +- `heartbeat_runs`: 11,360 runs between 2026-02-18 and 2026-03-13 +- summed `usage_json.inputTokens`: `2,272,142,368,952` +- summed `usage_json.cachedInputTokens`: `2,217,501,559,420` + +Those totals are not credible as true per-heartbeat usage for the observed prompt sizes. + +Supporting evidence: + +- `adapter.invoke.payload.prompt` averages were small: + - `codex_local`: ~193 chars average, 6,067 chars max + - `claude_local`: ~160 chars average, 1,160 chars max +- despite that, many `codex_local` runs report millions of input tokens +- one reused Codex session in local data spans 3,607 runs and recorded `inputTokens` growing up to `1,155,283,166` + +Interpretation: + +- for sessioned adapters, especially Codex, we are likely storing usage reported by the runtime as a **session total**, not a **per-run delta** +- this makes trend reporting, optimization work, and customer trust worse + +This does **not** mean there is no real token problem. It means we need a trustworthy baseline before we can judge optimization impact. + +### 2. Timer wakes currently throw away reusable task sessions + +In `server/src/services/heartbeat.ts`, `shouldResetTaskSessionForWake(...)` returns `true` for: + +- `wakeReason === "issue_assigned"` +- `wakeSource === "timer"` +- manual on-demand wakes + +That means many normal heartbeats skip saved task-session resume even when the workspace is stable. + +Local data supports the impact: + +- `timer/system` runs: 6,587 total +- only 976 had a previous session +- only 963 ended with the same session + +So timer wakes are the largest heartbeat path and are mostly not resuming prior task state. + +### 3. We repeatedly ask agents to reload the same task context + +The `paperclip` skill currently tells agents to do this on essentially every heartbeat: + +- fetch assignments +- fetch issue details +- fetch ancestor chain +- fetch full issue comments + +Current API shape reinforces that pattern: + +- `GET /api/issues/:id/comments` returns the full thread +- there is no `since`, cursor, digest, or summary endpoint for heartbeat consumption +- `GET /api/issues/:id` returns full enriched issue context, not a minimal delta payload + +This is safe but expensive. It forces the model to repeatedly consume unchanged information. + +### 4. Static instruction payloads are not separated cleanly from dynamic heartbeat prompts + +The user discussion suggested a bootstrap prompt. That is the right direction. + +Current state: + +- the UI exposes `bootstrapPromptTemplate` +- adapter execution paths do not currently use it +- several adapters prepend `instructionsFilePath` content directly into the per-run prompt or system prompt + +Result: + +- stable instructions are re-sent or re-applied in the same path as dynamic heartbeat content +- we are not deliberately optimizing for provider prompt caching + +### 5. We inject more skill surface than most agents need + +Local adapters inject repo skills into runtime skill directories. + +Important `codex_local` nuance: + +- Codex does not read skills directly from the active worktree. +- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`. +- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it. +- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land. +- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested. + +Current repo skill sizes: + +- `skills/paperclip/SKILL.md`: 17,441 bytes +- `.agents/skills/create-agent-adapter/SKILL.md`: 31,832 bytes +- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes +- `skills/para-memory-files/SKILL.md`: 3,978 bytes + +That is nearly 58 KB of skill markdown before any company-specific instructions. + +Not all of that is necessarily loaded into model context every run, but it increases startup surface area and should be treated as a token budget concern. + +## Principles + +We should optimize tokens under these rules: + +1. **Do not lose functionality.** Agents must still be able to resume work safely, understand why tasks exist, and act within governance rules. +2. **Prefer stable context over repeated context.** Unchanged instructions should not be resent through the most expensive path. +3. **Prefer deltas over full reloads.** Heartbeats should consume only what changed since the last useful run. +4. **Measure normalized deltas, not raw adapter claims.** Especially for sessioned CLIs. +5. **Keep escape hatches.** Board/manual runs may still want a forced fresh session. + +## Plan + +## Phase 1: Make token telemetry trustworthy + +This should happen first. + +### Changes + +- Store both: + - raw adapter-reported usage + - Paperclip-normalized per-run usage +- For sessioned adapters, compute normalized deltas against prior usage for the same persisted session. +- Add explicit fields for: + - `sessionReused` + - `taskSessionReused` + - `promptChars` + - `instructionsChars` + - `hasInstructionsFile` + - `skillSetHash` or skill count + - `contextFetchMode` (`full`, `delta`, `summary`) +- Add per-adapter parser tests that distinguish cumulative-session counters from per-run counters. + +### Why + +Without this, we cannot tell whether a reduction came from a real optimization or a reporting artifact. + +### Success criteria + +- per-run token totals stop exploding on long-lived sessions +- a resumed session’s usage curve is believable and monotonic at the session level, but not double-counted at the run level +- cost pages can show both raw and normalized numbers while we migrate + +## Phase 2: Preserve safe session reuse by default + +This is the highest-leverage behavior change. + +### Changes + +- Stop resetting task sessions on ordinary timer wakes. +- Keep resetting on: + - explicit manual “fresh run” invocations + - assignment changes + - workspace mismatch + - model mismatch / invalid resume errors +- Add an explicit wake flag like `forceFreshSession: true` when the board wants a reset. +- Record why a session was reused or reset in run metadata. + +### Why + +Timer wakes are the dominant heartbeat path. Resetting them destroys both session continuity and prompt cache reuse. + +### Success criteria + +- timer wakes resume the prior task session in the large majority of stable-workspace cases +- no increase in stale-session failures +- lower normalized input tokens per timer heartbeat + +## Phase 3: Separate static bootstrap context from per-heartbeat context + +This is the right version of the discussion’s bootstrap idea. + +### Changes + +- Implement `bootstrapPromptTemplate` in adapter execution paths. +- Use it only when starting a fresh session, not on resumed sessions. +- Keep `promptTemplate` intentionally small and stable: + - who I am + - what triggered this wake + - which task/comment/approval to prioritize +- Move long-lived setup text out of recurring per-run prompts where possible. +- Add UI guidance and warnings when `promptTemplate` contains high-churn or large inline content. + +### Why + +Static instructions and dynamic wake context have different cache behavior and should be modeled separately. + +For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent. + +### Success criteria + +- fresh-session prompts can remain richer without inflating every resumed heartbeat +- resumed prompts become short and structurally stable +- cache hit rates improve for session-preserving adapters + +## Phase 4: Make issue/task context incremental + +This is the biggest product change and likely the biggest real token saver after session reuse. + +### Changes + +Add heartbeat-oriented endpoints and skill behavior: + +- `GET /api/agents/me/inbox-lite` + - minimal assignment list + - issue id, identifier, status, priority, updatedAt, lastExternalCommentAt +- `GET /api/issues/:id/heartbeat-context` + - compact issue state + - parent-chain summary + - latest execution summary + - change markers +- `GET /api/issues/:id/comments?after=` or `?since=` + - return only new comments +- optional `GET /api/issues/:id/context-digest` + - server-generated compact summary for heartbeat use + +Update the `paperclip` skill so the default pattern becomes: + +1. fetch compact inbox +2. fetch compact task context +3. fetch only new comments unless this is the first read, a mention-triggered wake, or a cache miss +4. fetch full thread only on demand + +### Why + +Today we are using full-fidelity board APIs as heartbeat APIs. That is convenient but token-inefficient. + +### Success criteria + +- after first task acquisition, most heartbeats consume only deltas +- repeated blocked-task or long-thread work no longer replays the whole comment history +- mention-triggered wakes still have enough context to respond correctly + +## Phase 5: Add session compaction and controlled rotation + +This protects against long-lived session bloat. + +### Changes + +- Add rotation thresholds per adapter/session: + - turns + - normalized input tokens + - age + - cache hit degradation +- Before rotating, produce a structured carry-forward summary: + - current objective + - work completed + - open decisions + - blockers + - files/artifacts touched + - next recommended action +- Persist that summary in task session state or runtime state. +- Start the next session with: + - bootstrap prompt + - compact carry-forward summary + - current wake trigger + +### Why + +Even when reuse is desirable, some sessions become too expensive to keep alive indefinitely. + +### Success criteria + +- very long sessions stop growing without bound +- rotating a session does not cause loss of task continuity +- successful task completion rate stays flat or improves + +## Phase 6: Reduce unnecessary skill surface + +### Changes + +- Move from “inject all repo skills” to an allowlist per agent or per adapter. +- Default local runtime skill set should likely be: + - `paperclip` +- Add opt-in skills for specialized agents: + - `paperclip-create-agent` + - `para-memory-files` + - `create-agent-adapter` +- Expose active skill set in agent config and run metadata. +- For `codex_local`, either: + - run with a worktree-specific `CODEX_HOME`, or + - treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout + +### Why + +Most agents do not need adapter-authoring or memory-system skills on every run. + +### Success criteria + +- smaller startup instruction surface +- no loss of capability for specialist agents that explicitly need extra skills + +## Rollout Order + +Recommended order: + +1. telemetry normalization +2. timer-wake session reuse +3. bootstrap prompt implementation +4. heartbeat delta APIs + `paperclip` skill rewrite +5. session compaction/rotation +6. skill allowlists + +## Acceptance Metrics + +We should treat this plan as successful only if we improve both efficiency and task outcomes. + +Primary metrics: + +- normalized input tokens per successful heartbeat +- normalized input tokens per completed issue +- cache-hit ratio for sessioned adapters +- session reuse rate by invocation source +- fraction of heartbeats that fetch full comment threads + +Guardrail metrics: + +- task completion rate +- blocked-task rate +- stale-session failure rate +- manual intervention rate +- issue reopen rate after agent completion + +Initial targets: + +- 30% to 50% reduction in normalized input tokens per successful resumed heartbeat +- 80%+ session reuse on stable timer wakes +- 80%+ reduction in full-thread comment reloads after first task read +- no statistically meaningful regression in completion rate or failure rate + +## Concrete Engineering Tasks + +1. Add normalized usage fields and migration support for run analytics. +2. Patch sessioned adapter accounting to compute deltas from prior session totals. +3. Change `shouldResetTaskSessionForWake(...)` so timer wakes do not reset by default. +4. Implement `bootstrapPromptTemplate` end-to-end in adapter execution. +5. Add compact heartbeat context and incremental comment APIs. +6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior. +7. Add session rotation with carry-forward summaries. +8. Replace global skill injection with explicit allowlists. +9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime. + +## Recommendation + +Treat this as a two-track effort: + +- **Track A: correctness and no-regret wins** + - telemetry normalization + - timer-wake session reuse + - bootstrap prompt implementation +- **Track B: structural token reduction** + - delta APIs + - skill rewrite + - session compaction + - skill allowlists + +If we only do Track A, we will improve things, but agents will still re-read too much unchanged task context. + +If we only do Track B without fixing telemetry first, we will not be able to prove the gains cleanly. diff --git a/doc/plans/2026-03-13-agent-evals-framework.md b/doc/plans/2026-03-13-agent-evals-framework.md new file mode 100644 index 00000000..6c4cc55e --- /dev/null +++ b/doc/plans/2026-03-13-agent-evals-framework.md @@ -0,0 +1,775 @@ +# Agent Evals Framework Plan + +Date: 2026-03-13 + +## Context + +We need evals for the thing Paperclip actually ships: + +- agent behavior produced by adapter config +- prompt templates and bootstrap prompts +- skill sets and skill instructions +- model choice +- runtime policy choices that affect outcomes and cost + +We do **not** primarily need a fine-tuning pipeline. +We need a regression framework that can answer: + +- if we change prompts or skills, do agents still do the right thing? +- if we switch models, what got better, worse, or more expensive? +- if we optimize tokens, did we preserve task outcomes? +- can we grow the suite over time from real Paperclip usage? + +This plan is based on: + +- `doc/GOAL.md` +- `doc/PRODUCT.md` +- `doc/SPEC-implementation.md` +- `docs/agents-runtime.md` +- `doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md` +- Discussion #449: +- OpenAI eval best practices: +- Promptfoo docs: and +- LangSmith complex agent eval docs: +- Braintrust dataset/scorer docs: and + +## Recommendation + +Paperclip should take a **two-stage approach**: + +1. **Start with Promptfoo now** for narrow, prompt-and-skill behavior evals across models. +2. **Grow toward a first-party, repo-local eval harness in TypeScript** for full Paperclip scenario evals. + +So the recommendation is no longer “skip Promptfoo.” It is: + +- use Promptfoo as the fastest bootstrap layer +- keep eval cases and fixtures in this repo +- avoid making Promptfoo config the deepest long-term abstraction + +More specifically: + +1. The canonical eval definitions should live in this repo under a top-level `evals/` directory. +2. `v0` should use Promptfoo to run focused test cases across models and providers. +3. The longer-term harness should run **real Paperclip scenarios** against seeded companies/issues/agents, not just raw prompt completions. +4. The scoring model should combine: + - deterministic checks + - structured rubric scoring + - pairwise candidate-vs-baseline judging + - efficiency metrics from normalized usage/cost telemetry +5. The framework should compare **bundles**, not just models. + +A bundle is: + +- adapter type +- model id +- prompt template(s) +- bootstrap prompt template +- skill allowlist / skill content version +- relevant runtime flags + +That is the right unit because that is what actually changes behavior in Paperclip. + +## Why This Is The Right Shape + +### 1. We need to evaluate system behavior, not only prompt output + +Prompt-only tools are useful, but Paperclip’s real failure modes are often: + +- wrong issue chosen +- wrong API call sequence +- bad delegation +- failure to respect approval boundaries +- stale session behavior +- over-reading context +- claiming completion without producing artifacts or comments + +Those are control-plane behaviors. They require scenario setup, execution, and trace inspection. + +### 2. The repo is already TypeScript-first + +The existing monorepo already uses: + +- `pnpm` +- `tsx` +- `vitest` +- TypeScript across server, UI, shared contracts, and adapters + +A TypeScript-first harness will fit the repo and CI better than introducing a Python-first test subsystem as the default path. + +Python can stay optional later for specialty scorers or research experiments. + +### 3. We need provider/model comparison without vendor lock-in + +OpenAI’s guidance is directionally right: + +- eval early and often +- use task-specific evals +- log everything +- prefer pairwise/comparison-style judging over open-ended scoring + +But OpenAI’s Evals API is not the right control plane for Paperclip as the primary system because our target is explicitly multi-model and multi-provider. + +### 4. Hosted eval products are useful, and Promptfoo is the right bootstrap tool + +The current tradeoff: + +- Promptfoo is very good for local, repo-based prompt/provider matrices and CI integration. +- LangSmith is strong on trajectory-style agent evals. +- Braintrust has a clean dataset + scorer + experiment model and strong TypeScript support. + +The community suggestion is directionally right: + +- Promptfoo lets us start small +- it supports simple assertions like contains / not-contains / regex / custom JS +- it can run the same cases across multiple models +- it supports OpenRouter +- it can move into CI later + +That makes it the best `v0` tool for “did this prompt/skill/model change obviously regress?” + +But Paperclip should still avoid making a hosted platform or a third-party config format the core abstraction before we have our own stable eval model. + +The right move is: + +- start with Promptfoo for quick wins +- keep the data portable and repo-owned +- build a thin first-party harness around Paperclip concepts as the system grows +- optionally export to or integrate with other tools later if useful + +## What We Should Evaluate + +We should split evals into four layers. + +### Layer 1: Deterministic contract evals + +These should require no judge model. + +Examples: + +- agent comments on the assigned issue +- no mutation outside the agent’s company +- approval-required actions do not bypass approval flow +- task transitions are legal +- output contains required structured fields +- artifact links exist when the task required an artifact +- no full-thread refetch on delta-only cases once the API supports it + +These are cheap, reliable, and should be the first line of defense. + +### Layer 2: Single-step behavior evals + +These test narrow behaviors in isolation. + +Examples: + +- chooses the correct issue from inbox +- writes a reasonable first status comment +- decides to ask for approval instead of acting directly +- delegates to the correct report +- recognizes blocked state and reports it clearly + +These are the closest thing to prompt evals, but still framed in Paperclip terms. + +### Layer 3: End-to-end scenario evals + +These run a full heartbeat or short sequence of heartbeats against a seeded scenario. + +Examples: + +- new assignment pickup +- long-thread continuation +- mention-triggered clarification +- approval-gated hire request +- manager escalation +- workspace coding task that must leave a meaningful issue update + +These should evaluate both final state and trace quality. + +### Layer 4: Efficiency and regression evals + +These are not “did the answer look good?” evals. They are “did we preserve quality while improving cost/latency?” evals. + +Examples: + +- normalized input tokens per successful heartbeat +- normalized tokens per completed issue +- session reuse rate +- full-thread reload rate +- wall-clock duration +- cost per successful scenario + +This layer is especially important for token optimization work. + +## Core Design + +## 1. Canonical object: `EvalCase` + +Each eval case should define: + +- scenario setup +- target bundle(s) +- execution mode +- expected invariants +- scoring rubric +- tags/metadata + +Suggested shape: + +```ts +type EvalCase = { + id: string; + description: string; + tags: string[]; + setup: { + fixture: string; + agentId: string; + trigger: "assignment" | "timer" | "on_demand" | "comment" | "approval"; + }; + inputs?: Record; + checks: { + hard: HardCheck[]; + rubric?: RubricCheck[]; + pairwise?: PairwiseCheck[]; + }; + metrics: MetricSpec[]; +}; +``` + +The important part is that the case is about a Paperclip scenario, not a standalone prompt string. + +## 2. Canonical object: `EvalBundle` + +Suggested shape: + +```ts +type EvalBundle = { + id: string; + adapter: string; + model: string; + promptTemplate: string; + bootstrapPromptTemplate?: string; + skills: string[]; + flags?: Record; +}; +``` + +Every comparison run should say which bundle was tested. + +This avoids the common mistake of saying “model X is better” when the real change was model + prompt + skills + runtime behavior. + +## 3. Canonical output: `EvalTrace` + +We should capture a normalized trace for scoring: + +- run ids +- prompts actually sent +- session reuse metadata +- issue mutations +- comments created +- approvals requested +- artifacts created +- token/cost telemetry +- timing +- raw outputs + +The scorer layer should never need to scrape ad hoc logs. + +## Scoring Framework + +## 1. Hard checks first + +Every eval should start with pass/fail checks that can invalidate the run immediately. + +Examples: + +- touched wrong company +- skipped required approval +- no issue update produced +- returned malformed structured output +- marked task done without required artifact + +If a hard check fails, the scenario fails regardless of style or judge score. + +## 2. Rubric scoring second + +Rubric scoring should use narrow criteria, not vague “how good was this?” prompts. + +Good rubric dimensions: + +- task understanding +- governance compliance +- useful progress communication +- correct delegation +- evidence of completion +- concision / unnecessary verbosity + +Each rubric should be a small 0-1 or 0-2 decision, not a mushy 1-10 scale. + +## 3. Pairwise judging for candidate vs baseline + +OpenAI’s eval guidance is right that LLMs are better at discrimination than open-ended generation. + +So for non-deterministic quality checks, the default pattern should be: + +- run baseline bundle on the case +- run candidate bundle on the same case +- ask a judge model which is better on explicit criteria +- allow `baseline`, `candidate`, or `tie` + +This is better than asking a judge for an absolute quality score with no anchor. + +## 4. Efficiency scoring is separate + +Do not bury efficiency inside a single blended quality score. + +Record it separately: + +- quality score +- cost score +- latency score + +Then compute a summary decision such as: + +- candidate is acceptable only if quality is non-inferior and efficiency is improved + +That is much easier to reason about than one magic number. + +## Suggested Decision Rule + +For PR gating: + +1. No hard-check regressions. +2. No significant regression on required scenario pass rate. +3. No significant regression on key rubric dimensions. +4. If the change is token-optimization-oriented, require efficiency improvement on target scenarios. + +For deeper comparison reports, show: + +- pass rate +- pairwise wins/losses/ties +- median normalized tokens +- median wall-clock time +- cost deltas + +## Dataset Strategy + +We should explicitly build the dataset from three sources. + +### 1. Hand-authored seed cases + +Start here. + +These should cover core product invariants: + +- assignment pickup +- status update +- blocked reporting +- delegation +- approval request +- cross-company access denial +- issue comment follow-up + +These are small, clear, and stable. + +### 2. Production-derived cases + +Per OpenAI’s guidance, we should log everything and mine real usage for eval cases. + +Paperclip should grow eval coverage by promoting real runs into cases when we see: + +- regressions +- interesting failures +- edge cases +- high-value success patterns worth preserving + +The initial version can be manual: + +- take a real run +- redact/normalize it +- convert it into an `EvalCase` + +Later we can automate trace-to-case generation. + +### 3. Adversarial and guardrail cases + +These should intentionally probe failure modes: + +- approval bypass attempts +- wrong-company references +- stale context traps +- irrelevant long threads +- misleading instructions in comments +- verbosity traps + +This is where promptfoo-style red-team ideas can become useful later, but it is not the first slice. + +## Repo Layout + +Recommended initial layout: + +```text +evals/ + README.md + promptfoo/ + promptfooconfig.yaml + prompts/ + cases/ + cases/ + core/ + approvals/ + delegation/ + efficiency/ + fixtures/ + companies/ + issues/ + bundles/ + baseline/ + experiments/ + runners/ + scenario-runner.ts + compare-runner.ts + scorers/ + hard/ + rubric/ + pairwise/ + judges/ + rubric-judge.ts + pairwise-judge.ts + lib/ + types.ts + traces.ts + metrics.ts + reports/ + .gitignore +``` + +Why top-level `evals/`: + +- it makes evals feel first-class +- it avoids hiding them inside `server/` even though they span adapters and runtime behavior +- it leaves room for both TS and optional Python helpers later +- it gives us a clean place for Promptfoo `v0` config plus the later first-party runner + +## Execution Model + +The harness should support three modes. + +### Mode A: Cheap local smoke + +Purpose: + +- run on PRs +- keep cost low +- catch obvious regressions + +Characteristics: + +- 5 to 20 cases +- 1 or 2 bundles +- mostly hard checks and narrow rubrics + +### Mode B: Candidate vs baseline compare + +Purpose: + +- evaluate a prompt/skill/model change before merge + +Characteristics: + +- paired runs +- pairwise judging enabled +- quality + efficiency diff report + +### Mode C: Nightly broader matrix + +Purpose: + +- compare multiple models and bundles +- grow historical benchmark data + +Characteristics: + +- larger case set +- multiple models +- more expensive rubric/pairwise judging + +## CI and Developer Workflow + +Suggested commands: + +```sh +pnpm evals:smoke +pnpm evals:compare --baseline baseline/codex-default --candidate experiments/codex-lean-skillset +pnpm evals:nightly +``` + +PR behavior: + +- run `evals:smoke` on prompt/skill/adapter/runtime changes +- optionally trigger `evals:compare` for labeled PRs or manual runs + +Nightly behavior: + +- run larger matrix +- save report artifact +- surface trend lines on pass rate, pairwise wins, and efficiency + +## Framework Comparison + +## Promptfoo + +Best use for Paperclip: + +- prompt-level micro-evals +- provider/model comparison +- quick local CI integration +- custom JS assertions and custom providers +- bootstrap-layer evals for one skill or one agent workflow + +What changed in this recommendation: + +- Promptfoo is now the recommended **starting point** +- especially for “one skill, a handful of cases, compare across models” + +Why it still should not be the only long-term system: + +- its primary abstraction is still prompt/provider/test-case oriented +- Paperclip needs scenario setup, control-plane state inspection, and multi-step traces as first-class concepts + +Recommendation: + +- use Promptfoo first +- store Promptfoo config and cases in-repo under `evals/promptfoo/` +- use custom JS/TS assertions and, if needed later, a custom provider that calls Paperclip scenario runners +- do not make Promptfoo YAML the only canonical Paperclip eval format once we outgrow prompt-level evals + +## LangSmith + +What it gets right: + +- final response evals +- trajectory evals +- single-step evals + +Why not the primary system today: + +- stronger fit for teams already centered on LangChain/LangGraph +- introduces hosted/external workflow gravity before our own eval model is stable + +Recommendation: + +- copy the trajectory/final/single-step taxonomy +- do not adopt the platform as the default requirement + +## Braintrust + +What it gets right: + +- TypeScript support +- clean dataset/task/scorer model +- production logging to datasets +- experiment comparison over time + +Why not the primary system today: + +- still externalizes the canonical dataset and review workflow +- we are not yet at the maturity where hosted experiment management should define the shape of the system + +Recommendation: + +- borrow its dataset/scorer/experiment mental model +- revisit once we want hosted review and experiment history at scale + +## OpenAI Evals / Evals API + +What it gets right: + +- strong eval principles +- emphasis on task-specific evals +- continuous evaluation mindset + +Why not the primary system: + +- Paperclip must compare across models/providers +- we do not want our primary eval runner coupled to one model vendor + +Recommendation: + +- use the guidance +- do not use it as the core Paperclip eval runtime + +## First Implementation Slice + +The first version should be intentionally small. + +## Phase 0: Promptfoo bootstrap + +Build: + +- `evals/promptfoo/promptfooconfig.yaml` +- 5 to 10 focused cases for one skill or one agent workflow +- model matrix using the providers we care about most +- mostly deterministic assertions: + - contains + - not-contains + - regex + - custom JS assertions + +Target scope: + +- one skill, or one narrow workflow such as assignment pickup / first status update +- compare a small set of bundles across several models + +Success criteria: + +- we can run one command and compare outputs across models +- prompt/skill regressions become visible quickly +- the team gets signal before building heavier infrastructure + +## Phase 1: Skeleton and core cases + +Build: + +- `evals/` scaffold +- `EvalCase`, `EvalBundle`, `EvalTrace` types +- scenario runner for seeded local cases +- 10 hand-authored core cases +- hard checks only + +Target cases: + +- assigned issue pickup +- write progress comment +- ask for approval when required +- respect company boundary +- report blocked state +- avoid marking done without artifact/comment evidence + +Success criteria: + +- a developer can run a local smoke suite +- prompt/skill changes can fail the suite deterministically +- Promptfoo `v0` cases either migrate into or coexist with this layer cleanly + +## Phase 2: Pairwise and rubric layer + +Build: + +- rubric scorer interface +- pairwise judge runner +- candidate vs baseline compare command +- markdown/html report output + +Success criteria: + +- model/prompt bundle changes produce a readable diff report +- we can tell “better”, “worse”, or “same” on curated scenarios + +## Phase 3: Efficiency integration + +Build: + +- normalized token/cost metrics into eval traces +- cost and latency comparisons +- efficiency gates for token optimization work + +Dependency: + +- this should align with the telemetry normalization work in `2026-03-13-TOKEN-OPTIMIZATION-PLAN.md` + +Success criteria: + +- quality and efficiency can be judged together +- token-reduction work no longer relies on anecdotal improvements + +## Phase 4: Production-case ingestion + +Build: + +- tooling to promote real runs into new eval cases +- metadata tagging +- failure corpus growth process + +Success criteria: + +- the eval suite grows from real product behavior instead of staying synthetic + +## Initial Case Categories + +We should start with these categories: + +1. `core.assignment_pickup` +2. `core.progress_update` +3. `core.blocked_reporting` +4. `governance.approval_required` +5. `governance.company_boundary` +6. `delegation.correct_report` +7. `threads.long_context_followup` +8. `efficiency.no_unnecessary_reloads` + +That is enough to start catching the classes of regressions we actually care about. + +## Important Guardrails + +### 1. Do not rely on judge models alone + +Every important scenario needs deterministic checks first. + +### 2. Do not gate PRs on a single noisy score + +Use pass/fail invariants plus a small number of stable rubric or pairwise checks. + +### 3. Do not confuse benchmark score with product quality + +The suite must keep growing from real runs, otherwise it will become a toy benchmark. + +### 4. Do not evaluate only final output + +Trajectory matters for agents: + +- did they call the right Paperclip APIs? +- did they ask for approval? +- did they communicate progress? +- did they choose the right issue? + +### 5. Do not make the framework vendor-shaped + +Our eval model should survive changes in: + +- judge provider +- candidate provider +- adapter implementation +- hosted tooling choices + +## Open Questions + +1. Should the first scenario runner invoke the real server over HTTP, or call services directly in-process? + My recommendation: start in-process for speed, then add HTTP-mode coverage once the model stabilizes. + +2. Should we support Python scorers in v1? + My recommendation: no. Keep v1 all-TypeScript. + +3. Should we commit baseline outputs? + My recommendation: commit case definitions and bundle definitions, but keep run artifacts out of git. + +4. Should we add hosted experiment tracking immediately? + My recommendation: no. Revisit after the local harness proves useful. + +## Final Recommendation + +Start with Promptfoo for immediate, narrow model-and-prompt comparisons, then grow into a first-party `evals/` framework in TypeScript that evaluates **Paperclip scenarios and bundles**, not just prompts. + +Use this structure: + +- Promptfoo for `v0` bootstrap +- deterministic hard checks as the foundation +- rubric and pairwise judging for non-deterministic quality +- normalized efficiency metrics as a separate axis +- repo-local datasets that grow from real runs + +Use external tools selectively: + +- Promptfoo as the initial path for narrow prompt/provider tests +- Braintrust or LangSmith later if we want hosted experiment management + +But keep the canonical eval model inside the Paperclip repo and aligned to Paperclip’s actual control-plane behaviors. diff --git a/doc/plans/2026-03-13-features.md b/doc/plans/2026-03-13-features.md new file mode 100644 index 00000000..80c60a87 --- /dev/null +++ b/doc/plans/2026-03-13-features.md @@ -0,0 +1,780 @@ +# Feature specs + +## 1) Guided onboarding + first-job magic + +The repo already has `onboard`, `doctor`, `run`, deployment modes, and even agent-oriented onboarding text/skills endpoints, but there are also current onboarding/auth validation issues and an open “onboard failed” report. That means this is not just polish; it is product-critical. ([GitHub][1]) + +### Product decision + +Replace “configuration-first onboarding” with **interview-first onboarding**. + +### What we want + +- Ask 3–4 questions up front, not 20 settings. +- Generate the right path automatically: local solo, shared private, or public cloud. +- Detect what agent/runtime environment already exists. +- Make it normal to have Claude/OpenClaw/Codex help complete setup. +- End onboarding with a **real first task**, not a blank dashboard. + +### What we do not want + +- Provider jargon before value. +- “Go find an API key” as the default first instruction. +- A successful install that still leaves users unsure what to do next. + +### Proposed UX + +On first run, show an interview: + +```ts +type OnboardingProfile = { + useCase: "startup" | "agency" | "internal_team"; + companySource: "new" | "existing"; + deployMode: "local_solo" | "shared_private" | "shared_public"; + autonomyMode: "hands_on" | "hybrid" | "full_auto"; + primaryRuntime: "claude_code" | "codex" | "openclaw" | "other"; +}; +``` + +Questions: + +1. What are you building? +2. Is this a new company, an existing company, or a service/agency team? +3. Are you working solo on one machine, sharing privately with a team, or deploying publicly? +4. Do you want full auto, hybrid, or tight manual control? + +Then Paperclip should: + +- detect installed CLIs/providers/subscriptions +- recommend the matching deployment/auth mode +- generate a local `onboarding.txt` / LLM handoff prompt +- offer a button: **“Open this in Claude / copy setup prompt”** +- create starter objects: + + - company + - company goal + - CEO + - founding engineer or equivalent first report + - first suggested task + +### Backend / API + +- Add `GET /api/onboarding/recommendation` +- Add `GET /api/onboarding/llm-handoff.txt` +- Reuse existing invite/onboarding/skills patterns for local-first bootstrap +- Persist onboarding answers into instance config for later defaults + +### Acceptance criteria + +- Fresh install with a supported local runtime completes without manual JSON/env editing. +- User sees first live agent action before leaving onboarding. +- A blank dashboard is no longer the default post-install state. +- If a required dependency is missing, the error is prescriptive and fixable from the UI/CLI. + +### Non-goals + +- Account creation +- enterprise SSO +- perfect provider auto-detection for every runtime + +--- + +## 2) Board command surface, not generic chat + +There is a real tension here: the transcript says users want “chat with my CEO,” while the public product definition says Paperclip is **not a chatbot** and V1 communication is **tasks + comments only**. At the same time, the repo is already exploring plugin infrastructure and even a chat plugin via plugin SSE streaming. The clean resolution is: **make the core surface conversational, but keep the data model task/thread-centric; reserve full chat as an optional plugin**. ([GitHub][2]) + +### Product decision + +Build a **Command Composer** backed by issues/comments/approvals, not a separate chat subsystem. + +### What we want + +- “Talk to the CEO” feeling for the user. +- Every conversation ends up attached to a real company object. +- Strategy discussion can produce issues, artifacts, and approvals. + +### What we do not want + +- A blank “chat with AI” home screen disconnected from the org. +- Yet another agent-chat product. + +### Proposed UX + +Add a global composer with modes: + +```ts +type ComposerMode = "ask" | "task" | "decision"; +type ThreadScope = "company" | "project" | "issue" | "agent"; +``` + +Examples: + +- On dashboard: “Ask the CEO for a hiring plan” → creates a `strategy` issue/thread scoped to the company. +- On agent page: “Tell the designer to make this cleaner” → appends an instruction comment to an issue or spawns a new delegated task. +- On approval page: “Why are you asking to hire?” → appends a board comment to the approval context. + +Add issue kinds: + +```ts +type IssueKind = "task" | "strategy" | "question" | "decision"; +``` + +### Backend / data model + +Prefer extending existing `issues` rather than creating `chats`: + +- `issues.kind` +- `issues.scope` +- optional `issues.target_agent_id` +- comment metadata: `comment.intent = hint | correction | board_question | board_decision` + +### Acceptance criteria + +- A user can “ask CEO” from the dashboard and receive a response in a company-scoped thread. +- From that thread, the user can create/approve tasks with one click. +- No separate chat database is required for v1 of this feature. + +### Non-goals + +- consumer chat UX +- model marketplace +- general-purpose assistant unrelated to company context + +--- + +## 3) Live org visibility + explainability layer + +The core product promise is already visibility and governance, but right now the transcript makes clear that the UI is still too close to raw agent execution. The repo already has org charts, activity, heartbeat runs, costs, and agent detail surfaces; the missing piece is the explanatory layer above them. ([GitHub][1]) + +### Product decision + +Default the UI to **human-readable operational summaries**, with raw logs one layer down. + +### What we want + +- At company level: “who is active, what are they doing, what is moving between teams” +- At agent level: “what is the plan, what step is complete, what outputs were produced” +- At run level: “summary first, transcript second” + +### Proposed UX + +Company page: + +- org chart with live active-state indicators +- delegation animation between nodes when work moves +- current open priorities +- pending approvals +- burn / budget warning strip + +Agent page: + +- status card +- current issue +- plan checklist +- latest artifact(s) +- summary of last run +- expandable raw trace/logs + +Run page: + +- **Summary** +- **Steps** +- **Raw transcript / tool calls** + +### Backend / API + +Generate a run view model from current run/activity data: + +```ts +type RunSummary = { + runId: string; + headline: string; + objective: string | null; + currentStep: string | null; + completedSteps: string[]; + delegatedTo: { agentId: string; issueId?: string }[]; + artifactIds: string[]; + warnings: string[]; +}; +``` + +Phase 1 can derive this server-side from existing run logs/comments. Persist only if needed later. + +### Acceptance criteria + +- Board can tell what is happening without reading shell commands. +- Raw logs are still accessible, but not the default surface. +- First task / first hire / first completion moments are visibly celebrated. + +### Non-goals + +- overdesigned animation system +- perfect semantic summarization before core data quality exists + +--- + +## 4) Artifact system: attachments, file browser, previews + +This gap is already showing up in the repo. Storage is present, attachment endpoints exist, but current issues show that attachments are still effectively image-centric and comment attachment rendering is incomplete. At the same time, your transcript wants plans, docs, files, and generated web pages surfaced cleanly. ([GitHub][4]) + +### Product decision + +Introduce a first-class **Artifact** model that unifies: + +- uploaded/generated files +- workspace files of interest +- preview URLs +- generated docs/reports + +### What we want + +- Plans, specs, CSVs, markdown, PDFs, logs, JSON, HTML outputs +- easy discoverability from the issue/run/company pages +- a lightweight file browser for project workspaces +- preview links for generated websites/apps + +### What we do not want + +- forcing agents to paste everything inline into comments +- HTML stuffed into comment bodies as a workaround +- a full web IDE + +### Phase 1: fix the obvious gaps + +- Accept non-image MIME types for issue attachments +- Attach files to comments correctly +- Show file metadata + download/open on issue page + +### Phase 2: introduce artifacts + +```ts +type ArtifactKind = "attachment" | "workspace_file" | "preview" | "report_link"; + +interface Artifact { + id: string; + companyId: string; + issueId?: string; + runId?: string; + agentId?: string; + kind: ArtifactKind; + title: string; + mimeType?: string; + filename?: string; + sizeBytes?: number; + storageKind: "local_disk" | "s3" | "external_url"; + contentPath?: string; + previewUrl?: string; + metadata: Record; +} +``` + +### UX + +Issue page gets a **Deliverables** section: + +- Files +- Reports +- Preview links +- Latest generated artifact highlighted at top + +Project page gets a **Files** tab: + +- folder tree +- recent changes +- “Open produced files” shortcut + +### Preview handling + +For HTML/static outputs: + +- local deploy → open local preview URL +- shared/public deploy → host via configured preview service or static storage +- preview URL is registered back onto the issue as an artifact + +### Acceptance criteria + +- Agents can attach `.md`, `.txt`, `.json`, `.csv`, `.pdf`, and `.html`. +- Users can open/download them from the issue page. +- A generated static site can be opened from an issue without hunting through the filesystem. + +### Non-goals + +- browser IDE +- collaborative docs editor +- full object-storage admin UI + +--- + +## 5) Shared/cloud deployment + cloud runtimes + +The repo already has a clear deployment story in docs: `local_trusted`, `authenticated/private`, and `authenticated/public`, plus Tailscale guidance. The roadmap explicitly calls out cloud agents like Cursor / e2b. That means the next step is not inventing a deployment model; it is making the shared/cloud path canonical and production-usable. ([GitHub][5]) + +### Product decision + +Make **shared/private deploy** and **public/cloud deploy** first-class supported modes, and add **remote runtime drivers** for cloud-executed agents. + +### What we want + +- one instance a team can actually share +- local-first path that upgrades to private/public without a mental model change +- remote agent execution for non-local runtimes + +### Proposed architecture + +Separate **control plane** from **execution runtime** more explicitly: + +```ts +type RuntimeDriver = "local_process" | "remote_sandbox" | "webhook"; + +interface ExecutionHandle { + externalRunId: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; + previewUrl?: string; + logsUrl?: string; +} +``` + +First remote driver: `remote_sandbox` for e2b-style execution. + +### Deliverables + +- canonical deploy recipes: + + - local solo + - shared private (Tailscale/private auth) + - public cloud (managed Postgres + object storage + public URL) + +- runtime health page +- adapter/runtime capability matrix +- one official reference deployment path + +### UX + +New “Deployment” settings page: + +- instance mode +- auth/exposure +- storage/database status +- runtime drivers configured +- health and reachability checks + +### Acceptance criteria + +- Two humans can log into one authenticated/private instance and use it concurrently. +- A public deployment can run agents via at least one remote runtime. +- `doctor` catches missing public/private config and gives concrete fixes. + +### Non-goals + +- fully managed Paperclip SaaS +- every possible cloud provider in v1 + +--- + +## 6) Multi-human collaboration (minimal, not enterprise RBAC) + +This is the biggest deliberate departure from the current V1 spec. Publicly, V1 still says “single human board operator” and puts role-based human granularity out of scope. But the transcript is right that shared use is necessary if Paperclip is going to be real for teams. The key is to do a **minimal collaboration model**, not a giant permission system. ([GitHub][2]) + +### Product decision + +Ship **coarse multi-user company memberships**, not fine-grained enterprise RBAC. + +### Proposed roles + +```ts +type CompanyRole = "owner" | "admin" | "operator" | "viewer"; +``` + +- **owner**: instance/company ownership, user invites, config +- **admin**: manage org, agents, budgets, approvals +- **operator**: create/update issues, interact with agents, view artifacts +- **viewer**: read-only + +### Data model + +```ts +interface CompanyMembership { + userId: string; + companyId: string; + role: CompanyRole; + invitedByUserId: string; + createdAt: string; +} +``` + +Stretch goal later: + +- optional project/team scoping + +### What we want + +- shared dashboard for real teams +- user attribution in activity log +- simple invite flow +- company-level isolation preserved + +### What we do not want + +- per-field ACLs +- SCIM/SSO/enterprise admin consoles +- ten permission toggles per page + +### Acceptance criteria + +- Team of 3 can use one shared Paperclip instance. +- Every user action is attributed correctly in activity. +- Company membership boundaries are enforced. +- Viewer cannot mutate; operator/admin can. + +### Non-goals + +- enterprise RBAC +- cross-company matrix permissions +- multi-board governance logic in first cut + +--- + +## 7) Auto mode + interrupt/resume + +This is a product behavior issue, not a UI nicety. If agents cannot keep working or accept course correction without restarting, the autonomy model feels fake. + +### Product decision + +Make auto mode and mid-run interruption first-class runtime semantics. + +### What we want + +- Auto mode that continues until blocked by approvals, budgets, or explicit pause. +- Mid-run “you missed this” correction without losing session continuity. +- Clear state when an agent is waiting, blocked, or paused. + +### Proposed state model + +```ts +type RunState = + | "queued" + | "running" + | "waiting_approval" + | "waiting_input" + | "paused" + | "completed" + | "failed" + | "cancelled"; +``` + +Add board interjections as resumable input events: + +```ts +interface RunMessage { + runId: string; + authorUserId: string; + mode: "hint" | "correction" | "hard_override"; + body: string; + resumeCurrentSession: boolean; +} +``` + +### UX + +Buttons on active run: + +- Pause +- Resume +- Interrupt +- Abort +- Restart from scratch + +Interrupt opens a small composer that explicitly says: + +- continue current session +- or restart run + +### Acceptance criteria + +- A board comment can resume an active session instead of spawning a fresh one. +- Session ID remains stable for “continue” path. +- UI clearly distinguishes blocked vs. waiting vs. paused. + +### Non-goals + +- simultaneous multi-user live editing of the same run transcript +- perfect conversational UX before runtime semantics are fixed + +--- + +## 8) Cost safety + heartbeat/runtime hardening + +This is probably the most important immediate workstream. The transcript says token burn is the highest pain, and the repo currently has active issues around budget enforcement evidence, onboarding/auth validation, and circuit-breaker style waste prevention. Public docs already promise hard budgets, and the issue tracker is pointing at the missing operational protections. ([GitHub][6]) + +### Product decision + +Treat this as a **P0 runtime contract**, not a nice-to-have. + +### Part A: deterministic wake gating + +Do cheap, explicit work detection before invoking an LLM. + +```ts +type WakeReason = + | "new_assignment" + | "new_comment" + | "mention" + | "approval_resolved" + | "scheduled_scan" + | "manual"; +``` + +Rules: + +- if no new actionable input exists, do not call the model +- scheduled scan should be a cheap policy check first, not a full reasoning pass + +### Part B: budget contract + +Keep the existing public promise, but make it undeniable: + +- warning at 80% +- auto-pause at 100% +- visible audit trail +- explicit board override to continue + +### Part C: circuit breaker + +Add per-agent runtime guards: + +```ts +interface CircuitBreakerConfig { + enabled: boolean; + maxConsecutiveNoProgress: number; + maxConsecutiveFailures: number; + tokenVelocityMultiplier: number; +} +``` + +Trip when: + +- no issue/status/comment progress for N runs +- N failures in a row +- token spike vs rolling average + +### Part D: refactor heartbeat service + +Split current orchestration into modules: + +- wake detector +- checkout/lock manager +- adapter runner +- session manager +- cost recorder +- breaker evaluator +- event streamer + +### Part E: regression suite + +Mandatory automated proofs for: + +- onboarding/auth matrix +- 80/100 budget behavior +- no cross-company auth leakage +- no-spurious-wake idle behavior +- active-run resume/interruption +- remote runtime smoke + +### Acceptance criteria + +- Idle org with no new work does not generate model calls from heartbeat scans. +- 80% shows warning only. +- 100% pauses the agent and blocks continued execution until override. +- Circuit breaker pause is visible in audit/activity. +- Runtime modules have explicit contracts and are testable independently. + +### Non-goals + +- perfect autonomous optimization +- eliminating all wasted calls in every adapter/provider + +--- + +## 9) Project workspaces, previews, and PR handoff — without becoming GitHub + +This is the right way to resolve the code-workflow debate. The repo already has worktree-local instances, project `workspaceStrategy.provisionCommand`, and an RFC for adapter-level git worktree isolation. That is the correct architectural direction: **project execution policies and workspace isolation**, not built-in PR review. ([GitHub][7]) + +### Product decision + +Paperclip should manage the **issue → workspace → preview/PR → review handoff** lifecycle, but leave diffs/review/merge to external tools. + +### Proposed config + +Prefer repo-local project config: + +```yaml +# .paperclip/project.yml +execution: + workspaceStrategy: shared | worktree | ephemeral_container + deliveryMode: artifact | preview | pull_request + provisionCommand: "pnpm install" + teardownCommand: "pnpm clean" + preview: + command: "pnpm dev --port $PAPERCLIP_PREVIEW_PORT" + healthPath: "/" + ttlMinutes: 120 + vcs: + provider: github + repo: owner/repo + prPerIssue: true + baseBranch: main +``` + +### Rules + +- For non-code projects: `deliveryMode=artifact` +- For UI/app work: `deliveryMode=preview` +- For git-backed engineering projects: `deliveryMode=pull_request` +- For git-backed projects with `prPerIssue=true`, one issue maps to one isolated branch/worktree + +### UX + +Issue page shows: + +- workspace link/status +- preview URL if available +- PR URL if created +- “Reopen preview” button with TTL +- lifecycle: + + - `todo` + - `in_progress` + - `in_review` + - `done` + +### What we want + +- safe parallel agent work on one repo +- previewable output +- external PR review +- project-defined hooks, not hardcoded assumptions + +### What we do not want + +- built-in diff viewer +- merge queue +- Jira clone +- mandatory PRs for non-code work + +### Acceptance criteria + +- Multiple engineer agents can work concurrently without workspace contamination. +- When a project is in PR mode, the issue contains branch/worktree/preview/PR metadata. +- Preview can be reopened on demand until TTL expires. + +### Non-goals + +- replacing GitHub/GitLab +- universal preview hosting for every framework on day one + +--- + +## 10) Plugin system as the escape hatch + +The roadmap already includes plugins, GitHub discussions are active around it, and there is an open issue proposing an SSE bridge specifically to enable streaming plugin UIs such as chat, logs, and monitors. This is exactly the right place for optional surfaces. ([GitHub][1]) + +### Product decision + +Keep the control-plane core thin; put optional high-variance experiences into plugins. + +### First-party plugin targets + +- Chat +- Knowledge base / RAG +- Log tail / live build output +- Custom tracing or queues +- Doc editor / proposal builder + +### Plugin manifest + +```ts +interface PluginManifest { + id: string; + version: string; + requestedPermissions: ( + | "read_company" + | "read_issue" + | "write_issue_comment" + | "create_issue" + | "stream_ui" + )[]; + surfaces: ("company_home" | "issue_panel" | "agent_panel" | "sidebar")[]; + workerEntry: string; + uiEntry: string; +} +``` + +### Platform requirements + +- host ↔ worker action bridge +- SSE/UI streaming +- company-scoped auth +- permission declaration +- surface slots in UI + +### Acceptance criteria + +- A plugin can stream events to UI in real time. +- A chat plugin can converse without requiring chat to become the core Paperclip product. +- Plugin permissions are company-scoped and auditable. + +### Non-goals + +- plugins mutating core schema directly +- arbitrary privileged code execution without explicit permissions + +--- + +## Priority order I would use + +Given the repo state and the transcript, I would sequence it like this: + +**P0** + +1. Cost safety + heartbeat hardening +2. Guided onboarding + first-job magic +3. Shared/cloud deployment foundation +4. Artifact phase 1: non-image attachments + deliverables surfacing + +**P1** 5. Board command surface 6. Visibility/explainability layer 7. Auto mode + interrupt/resume 8. Minimal multi-user collaboration + +**P2** 9. Project workspace / preview / PR lifecycle 10. Plugin system + optional chat plugin 11. Template/preset expansion for startup vs agency vs internal-team onboarding + +Why this order: the current repo is already getting pressure on onboarding failures, auth/onboarding validation, budget enforcement, and wasted token burn. If those are shaky, everything else feels impressive but unsafe. ([GitHub][3]) + +## Bottom line + +The best synthesis is: + +- **Keep** Paperclip as the board-level control plane. +- **Do not** make chat, code review, or workflow-building the core identity. +- **Do** make the product feel conversational, visible, output-oriented, and shared. +- **Do** make coding workflows an integration surface via workspaces/previews/PR links. +- **Use plugins** for richer edges like chat and knowledge. + +That keeps the repo’s current product direction intact while solving almost every pain surfaced in the transcript. + +### Key references + +- README / positioning / roadmap / product boundaries. ([GitHub][1]) +- Product definition. ([GitHub][8]) +- V1 implementation spec and explicit non-goals. ([GitHub][2]) +- Core concepts and architecture. ([GitHub][9]) +- Deployment modes / Tailscale / local-to-cloud path. ([GitHub][5]) +- Developing guide: worktree-local instances, provision hooks, onboarding endpoints. ([GitHub][7]) +- Current issue pressure: onboarding failure, auth/onboarding validation, budget enforcement, circuit breaker, attachment gaps, plugin chat. ([GitHub][3]) + +[1]: https://github.com/paperclipai/paperclip "https://github.com/paperclipai/paperclip" +[2]: https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md "https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md" +[3]: https://github.com/paperclipai/paperclip/issues/704 "https://github.com/paperclipai/paperclip/issues/704" +[4]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md" +[5]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md" +[6]: https://github.com/paperclipai/paperclip/issues/692 "https://github.com/paperclipai/paperclip/issues/692" +[7]: https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md "https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md" +[8]: https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md "https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md" +[9]: https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md "https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md" diff --git a/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md new file mode 100644 index 00000000..68d4ad3c --- /dev/null +++ b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md @@ -0,0 +1,186 @@ +# Paperclip Skill Tightening Plan + +## Status + +Deferred follow-up. Do not include in the current token-optimization PR beyond documenting the plan. + +## Why This Is Deferred + +The `paperclip` skill is part of the critical control-plane safety surface. Tightening it may reduce fresh-session token use, but it also carries prompt-regression risk. We do not yet have evals that would let us safely prove behavior preservation across assignment handling, checkout rules, comment etiquette, approval workflows, and escalation paths. + +The current PR should ship the lower-risk infrastructure wins first: + +- telemetry normalization +- safe session reuse +- incremental issue/comment context +- bootstrap versus heartbeat prompt separation +- Codex worktree isolation + +## Current Problem + +Fresh runs still spend substantial input tokens even after the context-path fixes. The remaining large startup cost appears to come from loading the full `paperclip` skill and related instruction surface into context at run start. + +The skill currently mixes three kinds of content in one file: + +- hot-path heartbeat procedure used on nearly every run +- critical policy and safety invariants +- rare workflow/reference material that most runs do not need + +That structure is safe but expensive. + +## Goals + +- reduce first-run instruction tokens without weakening agent safety +- preserve all current Paperclip control-plane capabilities +- keep common heartbeat behavior explicit and easy for agents to follow +- move rare workflows and reference material out of the hot path +- create a structure that can later be evaluated systematically + +## Non-Goals + +- changing Paperclip API semantics +- removing required governance rules +- deleting rare workflows +- changing agent defaults in the current PR + +## Recommended Direction + +### 1. Split Hot Path From Lookup Material + +Restructure the skill into: + +- an always-loaded core section for the common heartbeat loop +- on-demand material for infrequent workflows and deep reference + +The core should cover only what is needed on nearly every wake: + +- auth and required headers +- inbox-first assignment retrieval +- mandatory checkout behavior +- `heartbeat-context` first +- incremental comment retrieval rules +- mention/self-assign exception +- blocked-task dedup +- status/comment/release expectations before exit + +### 2. Normalize The Skill Around One Canonical Procedure + +The same rules are currently expressed multiple times across: + +- heartbeat steps +- critical rules +- endpoint reference +- workflow examples + +Refactor so each operational fact has one primary home: + +- procedure +- invariant list +- appendix/reference + +This reduces prompt weight and lowers the chance of internal instruction drift. + +### 3. Compress Prose Into High-Signal Instruction Forms + +Rewrite the hot path using compact operational forms: + +- short ordered checklist +- flat invariant list +- minimal examples only where ambiguity would be risky + +Reduce: + +- narrative explanation +- repeated warnings already covered elsewhere +- large example payloads for common operations +- long endpoint matrices in the main body + +### 4. Move Rare Workflows Behind Explicit Triggers + +These workflows should remain available but should not dominate fresh-run context: + +- OpenClaw invite flow +- project setup flow +- planning `` writeback flow +- instructions-path update flow +- detailed link-formatting examples + +Recommended approach: + +- keep a short pointer in the main skill +- move detailed procedures into sibling skills or referenced docs that agents read only when needed + +### 5. Separate Policy From Reference + +The skill should distinguish: + +- mandatory operating rules +- endpoint lookup/reference +- business-process playbooks + +That separation makes it easier to evaluate prompt changes later and lets adapters or orchestration choose what must always be loaded. + +## Proposed Target Structure + +1. Purpose and authentication +2. Compact heartbeat procedure +3. Hard invariants +4. Required comment/update style +5. Triggered workflow index +6. Appendix/reference + +## Rollout Plan + +### Phase 1. Inventory And Measure + +- annotate the current skill by section and estimate token weight +- identify which sections are truly hot-path versus rare +- capture representative runs to compare before/after prompt size and behavior + +### Phase 2. Structural Refactor Without Semantic Changes + +- rewrite the main skill into the target structure +- preserve all existing rules and capabilities +- move rare workflow details into referenced companion material +- keep wording changes conservative + +### Phase 3. Validate Against Real Scenarios + +Run scenario checks for: + +- normal assigned heartbeat +- comment-triggered wake +- blocked-task dedup behavior +- approval-resolution wake +- delegation/subtask creation +- board handoff back to user +- plan-request handling + +### Phase 4. Decide Default Loading Strategy + +After validation, decide whether: + +- the entire main skill still loads by default, or +- only the compact core loads by default and rare sections are fetched on demand + +Do not change this loading policy without validation. + +## Risks + +- prompt degradation on control-plane safety rules +- agents forgetting rare but important workflows +- accidental removal of repeated wording that was carrying useful behavior +- introducing ambiguous instruction precedence between the core skill and companion materials + +## Preconditions Before Implementation + +- define acceptance scenarios for control-plane correctness +- add at least lightweight eval or scripted scenario coverage for key Paperclip flows +- confirm how adapter/bootstrap layering should load skill content versus references + +## Success Criteria + +- materially lower first-run input tokens for Paperclip-coordinated agents +- no regression in checkout discipline, issue updates, blocked handling, or delegation +- no increase in malformed API usage or ownership mistakes +- agents still complete rare workflows correctly when explicitly asked diff --git a/doc/plans/2026-03-13-plugin-kitchen-sink-example.md b/doc/plans/2026-03-13-plugin-kitchen-sink-example.md new file mode 100644 index 00000000..6a81c5cd --- /dev/null +++ b/doc/plans/2026-03-13-plugin-kitchen-sink-example.md @@ -0,0 +1,699 @@ +# Kitchen Sink Plugin Plan + +## Goal + +Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place. + +This plugin is meant to be: + +- a living reference implementation for contributors +- a manual test harness for the plugin runtime +- a discoverable demo of what plugins can actually do today + +It is not meant to be a polished end-user product plugin. + +## Why + +The current plugin system has a real API surface, but it is spread across: + +- SDK docs +- SDK types +- plugin spec prose +- two example plugins that each show only a narrow slice + +That makes it hard to answer basic questions like: + +- what can plugins render? +- what can plugin workers actually do? +- which surfaces are real versus aspirational? +- how should a new plugin be structured in this repo? + +The kitchen-sink plugin should answer those questions by example. + +## Success Criteria + +The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip. + +Concretely: + +- it installs from the bundled examples list +- it exposes at least one demo for every implemented worker API surface +- it exposes at least one demo for every host-mounted UI surface +- it clearly labels local-only / trusted-only demos +- it is safe enough for local development by default +- it doubles as a regression harness for plugin runtime changes + +## Constraints + +- Keep it instance-installed, not company-installed. +- Treat this as a trusted/local example plugin. +- Do not rely on cloud-safe runtime assumptions. +- Avoid destructive defaults. +- Avoid irreversible mutations unless they are clearly labeled and easy to undo. + +## Source Of Truth For This Plan + +This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec. + +Primary references: + +- `packages/plugins/sdk/README.md` +- `packages/plugins/sdk/src/types.ts` +- `packages/plugins/sdk/src/ui/types.ts` +- `packages/shared/src/constants.ts` +- `packages/shared/src/types/plugin.ts` + +## Current Surface Inventory + +### Worker/runtime APIs to demonstrate + +These are the concrete `ctx` clients currently exposed by the SDK: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.launchers` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.companies` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.data` +- `ctx.actions` +- `ctx.streams` +- `ctx.tools` +- `ctx.metrics` +- `ctx.logger` + +### UI surfaces to demonstrate + +Surfaces defined in the SDK: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `sidebar` +- `sidebarPanel` +- `detailTab` +- `taskDetailView` +- `projectSidebarItem` +- `toolbarButton` +- `contextMenuItem` +- `commentAnnotation` +- `commentContextMenuItem` + +### Current host confidence + +Confirmed or strongly indicated as mounted in the current app: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `detailTab` +- `projectSidebarItem` +- comment surfaces +- launcher infrastructure + +Need explicit validation before claiming full demo coverage: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- `toolbarButton` as direct slot, distinct from launcher placement +- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement + +The implementation should keep a small validation checklist for these before we call the plugin "complete". + +## Plugin Concept + +The plugin should be named: + +- display name: `Kitchen Sink (Example)` +- package: `@paperclipai/plugin-kitchen-sink-example` +- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example` + +Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style. + +Category mix: + +- `ui` +- `automation` +- `workspace` +- `connector` + +That is intentionally broad because the point is coverage. + +## UX Shape + +The plugin should have one main full-page demo console plus smaller satellites on other surfaces. + +### 1. Plugin page + +Primary route: the plugin `page` surface should be the central dashboard for all demos. + +Recommended page sections: + +- `Overview` + - what this plugin demonstrates + - current capabilities granted + - current host context +- `UI Surfaces` + - links explaining where each other surface should appear +- `Data + Actions` + - buttons and forms for bridge-driven worker demos +- `Events + Streams` + - emit event + - watch event log + - stream demo output +- `Paperclip Domain APIs` + - companies + - projects/workspaces + - issues + - goals + - agents +- `Local Workspace + Process` + - file listing + - file read/write scratch area + - child process demo +- `Jobs + Webhooks + Tools` + - job status + - webhook URL and recent deliveries + - declared tools +- `State + Entities + Assets` + - scoped state editor + - plugin entity inspector + - upload/generated asset demo +- `Observability` + - metrics written + - activity log samples + - latest worker logs + +### 2. Dashboard widget + +A compact widget on the main dashboard should show: + +- plugin health +- count of demos exercised +- recent event/stream activity +- shortcut to the full plugin page + +### 3. Project sidebar item + +Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab. + +### 4. Detail tabs + +Use detail tabs to demonstrate entity-context rendering on: + +- `project` +- `issue` +- `agent` +- `goal` + +Each tab should show: + +- the host context it received +- the relevant entity fetch via worker bridge +- one small action scoped to that entity + +### 5. Comment surfaces + +Use issue comment demos to prove comment-specific extension points: + +- `commentAnnotation` + - render parsed metadata below each comment + - show comment id, issue id, and a small derived status +- `commentContextMenuItem` + - add a menu action like `Copy Context To Kitchen Sink` + - action writes a plugin entity or state record for later inspection + +### 6. Settings page + +Custom `settingsPage` should be intentionally simple and operational: + +- `About` +- `Danger / Trust Model` +- demo toggles +- local process defaults +- workspace scratch-path behavior +- secret reference inputs +- event/job/webhook sample config + +This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics. + +## Feature Matrix + +Each implemented worker API should have a visible demo. + +### `ctx.config` + +Demo: + +- read live config +- show config JSON +- react to config changes without restart where possible + +### `ctx.events` + +Demos: + +- emit a plugin event +- subscribe to plugin events +- subscribe to a core Paperclip event such as `issue.created` +- show recent received events in a timeline + +### `ctx.jobs` + +Demos: + +- one scheduled heartbeat-style demo job +- one manual run button from the UI if host supports manual job trigger +- show last run result and timestamps + +### `ctx.launchers` + +Demos: + +- declare launchers in manifest +- optionally register one runtime launcher from the worker +- show launcher metadata on the plugin page + +### `ctx.http` + +Demo: + +- make a simple outbound GET request to a safe endpoint +- show status code, latency, and JSON result + +Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs. + +### `ctx.secrets` + +Demo: + +- operator enters a secret reference in config +- plugin resolves it on demand +- UI only shows masked result length / success status, never raw secret + +### `ctx.assets` + +Demos: + +- generate a text asset from the UI +- optionally upload a tiny JSON blob or screenshot-like text file +- show returned asset URL + +### `ctx.activity` + +Demo: + +- button to write a plugin activity log entry against current company/entity + +### `ctx.state` + +Demos: + +- instance-scoped state +- company-scoped state +- project-scoped state +- issue-scoped state +- delete/reset controls + +Use a small state inspector/editor on the plugin page. + +### `ctx.entities` + +Demos: + +- create plugin-owned sample records +- list/filter them +- show one realistic use case such as "copied comments" or "demo sync records" + +### `ctx.projects` + +Demos: + +- list projects +- list project workspaces +- resolve primary workspace +- resolve workspace for issue + +### `ctx.companies` + +Demo: + +- list companies and show current selected company + +### `ctx.issues` + +Demos: + +- list issues in current company +- create issue +- update issue status/title +- list comments +- create comment + +### `ctx.agents` + +Demos: + +- list agents +- invoke one agent with a test prompt +- pause/resume where safe + +Agent mutation controls should be behind an explicit warning. + +### `ctx.agents.sessions` + +Demos: + +- create agent chat session +- send message +- stream events back to the UI +- close session + +This is a strong candidate for the best "wow" demo on the plugin page. + +### `ctx.goals` + +Demos: + +- list goals +- create goal +- update status/title + +### `ctx.data` + +Use throughout the plugin for all read-side bridge demos. + +### `ctx.actions` + +Use throughout the plugin for all mutation-side bridge demos. + +### `ctx.streams` + +Demos: + +- live event log stream +- token-style stream from an agent session relay +- fake progress stream for a long-running action + +### `ctx.tools` + +Demos: + +- declare 2-3 simple agent tools +- tool 1: echo/diagnostics +- tool 2: project/workspace summary +- tool 3: create issue or write plugin state + +The plugin page should list declared tools and show example input payloads. + +### `ctx.metrics` + +Demo: + +- write a sample metric on each major demo action +- surface a small recent metrics table in the plugin page + +### `ctx.logger` + +Demo: + +- every action logs structured entries +- plugin settings `Status` page then doubles as the log viewer + +## Local Workspace And Process Demos + +The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata. + +The kitchen-sink plugin should demonstrate that explicitly. + +### Workspace demos + +- list files from a selected workspace +- read a file +- write to a plugin-owned scratch file +- optionally search files with `rg` if available + +### Process demos + +- run a short-lived command like `pwd`, `ls`, or `git status` +- stream stdout/stderr back to UI +- show exit code and timing + +Important safeguards: + +- default commands must be read-only +- no shell interpolation from arbitrary free-form input in v1 +- provide a curated command list or a strongly validated command form +- clearly label this area as local-only and trusted-only + +## Proposed Manifest Coverage + +The plugin should aim to declare: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `detailTab` for `project`, `issue`, `agent`, `goal` +- `projectSidebarItem` +- `commentAnnotation` +- `commentContextMenuItem` + +Then, after host validation, add if supported: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- `toolbarButton` +- `contextMenuItem` + +It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering. + +## Proposed Package Layout + +New package: + +- `packages/plugins/examples/plugin-kitchen-sink-example/` + +Expected files: + +- `package.json` +- `README.md` +- `tsconfig.json` +- `src/index.ts` +- `src/manifest.ts` +- `src/worker.ts` +- `src/ui/index.tsx` +- `src/ui/components/...` +- `src/ui/hooks/...` +- `src/lib/...` +- optional `scripts/build-ui.mjs` if UI bundling needs esbuild + +## Proposed Internal Architecture + +### Worker modules + +Recommended split: + +- `src/worker.ts` + - plugin definition and wiring +- `src/worker/data.ts` + - `ctx.data.register(...)` +- `src/worker/actions.ts` + - `ctx.actions.register(...)` +- `src/worker/events.ts` + - event subscriptions and event log buffer +- `src/worker/jobs.ts` + - scheduled job handlers +- `src/worker/tools.ts` + - tool declarations and handlers +- `src/worker/local-runtime.ts` + - file/process demos +- `src/worker/demo-store.ts` + - helpers for state/entities/assets/metrics + +### UI modules + +Recommended split: + +- `src/ui/index.tsx` + - exported slot components +- `src/ui/page/KitchenSinkPage.tsx` +- `src/ui/settings/KitchenSinkSettingsPage.tsx` +- `src/ui/widgets/KitchenSinkDashboardWidget.tsx` +- `src/ui/tabs/ProjectKitchenSinkTab.tsx` +- `src/ui/tabs/IssueKitchenSinkTab.tsx` +- `src/ui/tabs/AgentKitchenSinkTab.tsx` +- `src/ui/tabs/GoalKitchenSinkTab.tsx` +- `src/ui/comments/KitchenSinkCommentAnnotation.tsx` +- `src/ui/comments/KitchenSinkCommentMenuItem.tsx` +- `src/ui/shared/...` + +## Configuration Schema + +The plugin should have a substantial but understandable `instanceConfigSchema`. + +Recommended config fields: + +- `enableDangerousDemos` +- `enableWorkspaceDemos` +- `enableProcessDemos` +- `showSidebarEntry` +- `showSidebarPanel` +- `showProjectSidebarItem` +- `showCommentAnnotation` +- `showCommentContextMenuItem` +- `showToolbarLauncher` +- `defaultDemoCompanyId` optional +- `secretRefExample` +- `httpDemoUrl` +- `processAllowedCommands` +- `workspaceScratchSubdir` + +Defaults should keep risky behavior off. + +## Safety Defaults + +Default posture: + +- UI and read-only demos on +- mutating domain demos on but explicitly labeled +- process demos off by default +- no arbitrary shell input by default +- no raw secret rendering ever + +## Phased Build Plan + +### Phase 1: Core plugin skeleton + +- scaffold package +- add manifest, worker, UI entrypoints +- add README +- make it appear in bundled examples list + +### Phase 2: Core, confirmed UI surfaces + +- plugin page +- settings page +- dashboard widget +- project sidebar item +- detail tabs + +### Phase 3: Core worker APIs + +- config +- state +- entities +- companies/projects/issues/goals +- data/actions +- metrics/logger/activity + +### Phase 4: Real-time and automation APIs + +- streams +- events +- jobs +- webhooks +- agent sessions +- tools + +### Phase 5: Local trusted runtime demos + +- workspace file demos +- child process demos +- guarded by config + +### Phase 6: Secondary UI surfaces + +- comment annotation +- comment context menu item +- launchers + +### Phase 7: Validation-only surfaces + +Validate whether the current host truly mounts: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- direct-slot `toolbarButton` +- direct-slot `contextMenuItem` + +If mounted, add demos. +If not mounted, document them as SDK-defined but host-pending. + +## Documentation Deliverables + +The plugin should ship with a README that includes: + +- what it demonstrates +- which surfaces are local-only +- how to install it +- where each UI surface should appear +- a mapping from demo card to SDK API + +It should also be referenced from plugin docs as the "reference everything plugin". + +## Testing And Verification + +Minimum verification: + +- package typecheck/build +- install from bundled example list +- page loads +- widget appears +- project tab appears +- comment surfaces render +- settings page loads +- key actions succeed + +Recommended manual checklist: + +- create issue from plugin +- create goal from plugin +- emit and receive plugin event +- stream action output +- open agent session and receive streamed reply +- upload an asset +- write plugin activity log +- run a safe local process demo + +## Open Questions + +1. Should the process demo remain curated-command-only in the first pass? + Recommendation: yes. + +2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically? + Recommendation: no. Make creation explicit. + +3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired? + Recommendation: yes, but label them as `SDK-defined / host validation pending`. + +4. Should agent mutation demos include pause/resume by default? + Recommendation: probably yes, but behind a warning block. + +5. Should this plugin be treated as a supported regression harness in CI later? + Recommendation: yes. Long term, this should be the plugin-runtime smoke test package. + +## Recommended Next Step + +If this plan looks right, the next implementation pass should start by building only: + +- package skeleton +- page +- settings page +- dashboard widget +- one project detail tab +- one issue detail tab +- the basic worker/action/data/state/event scaffolding + +That is enough to lock the architecture before filling in every demo surface. diff --git a/doc/plans/2026-03-13-workspace-product-model-and-work-product.md b/doc/plans/2026-03-13-workspace-product-model-and-work-product.md new file mode 100644 index 00000000..ae5b8e79 --- /dev/null +++ b/doc/plans/2026-03-13-workspace-product-model-and-work-product.md @@ -0,0 +1,1126 @@ +# Workspace Product Model, Work Product, and PR Flow + +## Context + +Paperclip needs to support two very different but equally valid ways of working: + +- a solo developer working directly on `master`, or in a folder that is not even a git repo +- a larger engineering workflow with isolated branches, previews, pull requests, and cleanup automation + +Today, Paperclip already has the beginnings of this model: + +- `projects` can carry execution workspace policy +- `project_workspaces` already exist as a durable project-scoped object +- issues can carry execution workspace settings +- runtime services can be attached to a workspace or issue + +What is missing is a clear product model and UI that make these capabilities understandable and operable. + +The main product risk is overloading one concept to do too much: + +- making subissues do the job of branches or PRs +- making projects too infrastructure-heavy +- making workspaces so hidden that users cannot form a mental model +- making Paperclip feel like a code review tool instead of a control plane + +## Goals + +1. Keep `project` lightweight enough to remain a planning container. +2. Make workspace behavior understandable for both git and non-git projects. +3. Support three real workflows without forcing one: + - shared workspace / direct-edit workflows + - isolated issue workspace workflows + - long-lived branch or operator integration workflows +4. Provide a first-class place to see the outputs of work: + - previews + - PRs + - branches + - commits + - documents and artifacts +5. Keep the main navigation and task board simple. +6. Seamlessly upgrade existing Paperclip users to the new model without forcing disruptive reconfiguration. +7. Support cloud-hosted Paperclip deployments where execution happens in remote or adapter-managed environments rather than local workers. + +## Non-Goals + +- Turning Paperclip into a full code review product +- Requiring every issue to have its own branch or PR +- Requiring every project to configure code/workspace automation +- Making workspaces a top-level global navigation primitive in V1 +- Requiring a local filesystem path or local git checkout to use workspace-aware execution + +## Core Product Decisions + +### 1. Project stays the planning object + +A `project` remains the thing that groups work around a deliverable or initiative. + +It may have: + +- no code at all +- one default codebase/workspace +- several codebases/workspaces + +Projects are not required to become heavyweight. + +### 2. Project workspace is a first-class object, but scoped under project + +A `project workspace` is the durable codebase or root environment for a project. + +Examples: + +- a local folder on disk +- a git repo checkout +- a monorepo package root +- a non-git design/doc folder +- a remote adapter-managed codebase reference + +This is the stable anchor that operators configure once. + +It should not be a top-level sidebar item in the main app. It should live under the project experience. + +### 3. Execution workspace is a first-class runtime object + +An `execution workspace` is where a specific run or issue actually executes. + +Examples: + +- the shared project workspace itself +- an isolated git worktree +- a long-lived operator branch checkout +- an adapter-managed remote sandbox +- a cloud agent provider's isolated branch/session environment + +This object must be recorded explicitly so that Paperclip can: + +- show where work happened +- attach previews and runtime services +- link PRs and branches +- decide cleanup behavior +- support reuse across multiple related issues + +### 4. PRs are work product, not the core issue model + +A PR is an output of work, not the planning unit. + +Paperclip should treat PRs as a type of work product linked back to: + +- the issue +- the execution workspace +- optionally the project workspace + +Git-specific automation should live under workspace policy, not under the core issue abstraction. + +### 5. Existing users must upgrade automatically + +Paperclip already has users and existing project/task data. Any new model must preserve continuity. + +The product should default existing installs into a sensible compatibility mode: + +- existing projects without workspace configuration continue to work unchanged +- existing `project_workspaces` become the durable `project workspace` objects +- existing project execution workspace policy is mapped forward rather than discarded +- issues without explicit workspace fields continue to inherit current behavior + +This migration should feel additive, not like a mandatory re-onboarding flow. + +### 6. Cloud-hosted Paperclip must be a first-class deployment mode + +Paperclip cannot assume that it is running on the same machine as the code. + +In cloud deployments, Paperclip may: + +- run on Vercel or another serverless host +- have no long-lived local worker process +- delegate execution to a remote coding agent or provider-managed sandbox +- receive back a branch, PR, preview URL, or artifact from that remote environment + +The model therefore must be portable: + +- `project workspace` may be remote-managed, not local +- `execution workspace` may have no local `cwd` +- `runtime services` may be tracked by provider reference and URL rather than a host process +- work product harvesting must handle externally owned previews and PRs + +### 7. Subissues remain planning and ownership structure + +Subissues are for decomposition and parallel ownership. + +They are not the same thing as: + +- a branch +- a worktree +- a PR +- a preview + +They may correlate with those things, but they should not be overloaded to mean them. + +## Terminology + +Use these terms consistently in product copy: + +- `Project`: planning container +- `Project workspace`: durable configured codebase/root +- `Execution workspace`: actual runtime workspace used for issue execution +- `Isolated issue workspace`: user-facing term for an issue-specific derived workspace +- `Work product`: previews, PRs, branches, commits, artifacts, docs +- `Runtime service`: a process or service Paperclip owns or tracks for a workspace + +Use these terms consistently in migration and deployment messaging: + +- `Compatible mode`: existing behavior preserved without new workspace automation +- `Adapter-managed workspace`: workspace realized by a remote or cloud execution provider + +Avoid teaching users that "workspace" always means "git worktree on my machine". + +## Product Object Model + +## 1. Project + +Existing object. No fundamental change in role. + +### Required behavior + +- can exist without code/workspace configuration +- can have zero or more project workspaces +- can define execution defaults that new issues inherit + +### Proposed fields + +- `id` +- `companyId` +- `name` +- `description` +- `status` +- `goalIds` +- `leadAgentId` +- `targetDate` +- `executionWorkspacePolicy` +- `workspaces[]` +- `primaryWorkspace` + +## 2. Project Workspace + +Durable, configured, project-scoped codebase/root object. + +This should evolve from the current `project_workspaces` table into a more explicit product object. + +### Motivation + +This separates: + +- "what codebase/root does this project use?" + +from: + +- "what temporary execution environment did this issue run in?" + +That keeps the model simple for solo users while still supporting advanced automation. +It also lets cloud-hosted Paperclip deployments point at codebases and remotes without pretending the Paperclip host has direct filesystem access. + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `name` +- `sourceType` + - `local_path` + - `git_repo` + - `remote_managed` + - `non_git_path` +- `cwd` +- `repoUrl` +- `defaultRef` +- `isPrimary` +- `visibility` + - `default` + - `advanced` +- `setupCommand` +- `cleanupCommand` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceType=non_git_path` is important so non-git projects are first-class. +- `setupCommand` and `cleanupCommand` should be allowed here for workspace-root bootstrap, even when isolated execution is not used. +- For a monorepo, multiple project workspaces may point at different roots or packages under one repo. +- `sourceType=remote_managed` is important for cloud deployments where the durable codebase is defined by provider/repo metadata rather than a local checkout path. + +## 3. Project Execution Workspace Policy + +Project-level defaults for how issues execute. + +This is the main operator-facing configuration surface. + +### Motivation + +This lets Paperclip support: + +- direct editing in a shared workspace +- isolated workspaces for issue parallelism +- long-lived integration branch workflows +- remote cloud-agent execution that returns a branch or PR + +without forcing every issue or agent to expose low-level runtime configuration. + +### Proposed fields + +- `enabled: boolean` +- `defaultMode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_default` +- `allowIssueOverride: boolean` +- `defaultProjectWorkspaceId: uuid | null` +- `workspaceStrategy` + - `type` + - `project_primary` + - `git_worktree` + - `adapter_managed` + - `baseRef` + - `branchTemplate` + - `worktreeParentDir` + - `provisionCommand` + - `teardownCommand` +- `branchPolicy` + - `namingTemplate` + - `allowReuseExisting` + - `preferredOperatorBranch` +- `pullRequestPolicy` + - `mode` + - `disabled` + - `manual` + - `agent_may_open_draft` + - `approval_required_to_open` + - `approval_required_to_mark_ready` + - `baseBranch` + - `titleTemplate` + - `bodyTemplate` +- `runtimePolicy` + - `allowWorkspaceServices` + - `defaultServicesProfile` + - `autoHarvestOwnedUrls` +- `cleanupPolicy` + - `mode` + - `manual` + - `when_issue_terminal` + - `when_pr_closed` + - `retention_window` + - `retentionHours` + - `keepWhilePreviewHealthy` + - `keepWhileOpenPrExists` + +## 4. Issue Workspace Binding + +Issue-level selection of execution behavior. + +This should remain lightweight in the normal case and only surface richer controls when relevant. + +### Motivation + +Not every issue in a code project should create a new derived workspace. + +Examples: + +- a tiny fix can run in the shared workspace +- three related issues may intentionally share one integration branch +- a solo operator may be working directly on `master` + +### Proposed fields on `issues` + +- `projectWorkspaceId: uuid | null` +- `executionWorkspacePreference` + - `inherit` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `reuse_existing` +- `preferredExecutionWorkspaceId: uuid | null` +- `executionWorkspaceSettings` + - keep advanced per-issue override fields here + +### Rules + +- if the project has no workspace automation, these fields may all be null +- if the project has one primary workspace, issue creation should default to it silently +- `reuse_existing` is advanced-only and should target active execution workspaces, not the whole workspace universe +- existing issues without these fields should behave as `inherit` during migration + +## 5. Execution Workspace + +A durable record for a shared or derived runtime workspace. + +This is the missing object that makes cleanup, previews, PRs, and branch reuse tractable. + +### Motivation + +Without an explicit `execution workspace` record, Paperclip has nowhere stable to attach: + +- derived branch/worktree identity +- active preview ownership +- PR linkage +- cleanup state +- "reuse this existing integration branch" behavior +- remote provider session identity + +### Proposed new object + +`execution_workspaces` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `projectWorkspaceId` +- `sourceIssueId` +- `mode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_managed` +- `strategyType` + - `project_primary` + - `git_worktree` + - `adapter_managed` +- `name` +- `status` + - `active` + - `idle` + - `in_review` + - `archived` + - `cleanup_failed` +- `cwd` +- `repoUrl` +- `baseRef` +- `branchName` +- `providerRef` +- `providerType` + - `local_fs` + - `git_worktree` + - `adapter_managed` + - `cloud_sandbox` +- `derivedFromExecutionWorkspaceId` +- `lastUsedAt` +- `openedAt` +- `closedAt` +- `cleanupEligibleAt` +- `cleanupReason` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceIssueId` is the issue that originally caused the workspace to be created, not necessarily the only issue linked to it later. +- multiple issues may link to the same execution workspace in a long-lived branch workflow. +- `cwd` may be null for remote execution workspaces; provider identity and work product links still make the object useful. + +## 6. Issue-to-Execution Workspace Link + +An issue may need to link to one or more execution workspaces over time. + +Examples: + +- an issue begins in a shared workspace and later moves to an isolated one +- a failed attempt is archived and a new workspace is created +- several issues intentionally share one operator branch workspace + +### Proposed object + +`issue_execution_workspaces` + +### Proposed fields + +- `issueId` +- `executionWorkspaceId` +- `relationType` + - `current` + - `historical` + - `preferred` +- `createdAt` +- `updatedAt` + +### UI simplification + +Most issues should only show one current workspace in the main UI. Historical links belong in advanced/history views. + +## 7. Work Product + +User-facing umbrella concept for outputs of work. + +### Motivation + +Paperclip needs a single place to show: + +- "here is the preview" +- "here is the PR" +- "here is the branch" +- "here is the commit" +- "here is the artifact/report/doc" + +without turning issues into a raw dump of adapter details. + +### Proposed new object + +`issue_work_products` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `issueId` +- `executionWorkspaceId` +- `runtimeServiceId` +- `type` + - `preview_url` + - `runtime_service` + - `pull_request` + - `branch` + - `commit` + - `artifact` + - `document` +- `provider` + - `paperclip` + - `github` + - `gitlab` + - `vercel` + - `netlify` + - `custom` +- `externalId` +- `title` +- `url` +- `status` + - `active` + - `ready_for_review` + - `merged` + - `closed` + - `failed` + - `archived` +- `reviewState` + - `none` + - `needs_board_review` + - `approved` + - `changes_requested` +- `isPrimary` +- `healthStatus` + - `unknown` + - `healthy` + - `unhealthy` +- `summary` +- `metadata` +- `createdByRunId` +- `createdAt` +- `updatedAt` + +### Behavior + +- PRs are stored here as `type=pull_request` +- previews are stored here as `type=preview_url` or `runtime_service` +- Paperclip-owned processes should update health/status automatically +- external providers should at least store link, provider, external id, and latest known state +- cloud agents should be able to create work product records without Paperclip owning the execution host + +## Page and UI Model + +## 1. Global Navigation + +Do not add `Workspaces` as a top-level sidebar item in V1. + +### Motivation + +That would make the whole product feel infra-heavy, even for companies that do not use code automation. + +### Global nav remains + +- Dashboard +- Inbox +- Companies +- Agents +- Goals +- Projects +- Issues +- Approvals + +Workspaces and work product should be surfaced through project and issue detail views. + +## 2. Project Detail + +Add a project sub-navigation that keeps planning first and code second. + +### Tabs + +- `Overview` +- `Issues` +- `Code` +- `Activity` + +Optional future: + +- `Outputs` + +### `Overview` tab + +Planning-first summary: + +- project status +- goals +- lead +- issue counts +- top-level progress +- latest major work product summaries + +### `Issues` tab + +- default to top-level issues only +- show parent issue rollups: + - child count + - `x/y` done + - active preview/PR badges +- optional toggle: `Show subissues` + +### `Code` tab + +This is the main workspace configuration and visibility surface. + +#### Section: `Project Workspaces` + +List durable project workspaces for the project. + +Card/list columns: + +- workspace name +- source type +- path or repo +- default ref +- primary/default badge +- active execution workspaces count +- active issue count +- active preview count +- hosting type / provider when remote-managed + +Actions: + +- `Add workspace` +- `Edit` +- `Set default` +- `Archive` + +#### Section: `Execution Defaults` + +Fields: + +- `Enable workspace automation` +- `Default issue execution mode` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + - `Adapter default` +- `Default codebase` +- `Allow issue override` + +#### Section: `Provisioning` + +Fields: + +- `Setup command` +- `Cleanup command` +- `Implementation` + - `Shared workspace` + - `Git worktree` + - `Adapter-managed` +- `Base ref` +- `Branch naming template` +- `Derived workspace parent directory` + +Hide git-specific fields when the selected workspace is not git-backed. +Hide local-path-specific fields when the selected workspace is remote-managed. + +#### Section: `Pull Requests` + +Fields: + +- `PR workflow` + - `Disabled` + - `Manual` + - `Agent may open draft PR` + - `Approval required to open PR` + - `Approval required to mark ready` +- `Default base branch` +- `PR title template` +- `PR body template` + +#### Section: `Previews and Runtime` + +Fields: + +- `Allow workspace runtime services` +- `Default services profile` +- `Harvest owned preview URLs` +- `Track external preview URLs` + +#### Section: `Cleanup` + +Fields: + +- `Cleanup mode` + - `Manual` + - `When issue is terminal` + - `When PR closes` + - `After retention window` +- `Retention window` +- `Keep while preview is active` +- `Keep while PR is open` + +## 3. Add Project Workspace Flow + +Entry point: `Project > Code > Add workspace` + +### Form fields + +- `Name` +- `Source type` + - `Local folder` + - `Git repo` + - `Non-git folder` + - `Remote managed` +- `Local path` +- `Repository URL` +- `Remote provider` +- `Remote workspace reference` +- `Default ref` +- `Set as default workspace` +- `Setup command` +- `Cleanup command` + +### Behavior + +- if source type is non-git, hide branch/PR-specific setup +- if source type is git, show ref and optional advanced branch fields +- if source type is remote-managed, show provider/reference fields and hide local-path-only configuration +- for simple solo users, this can be one path field and one save button + +## 4. Issue Create Flow + +Issue creation should stay simple by default. + +### Default behavior + +If the selected project: + +- has no workspace automation: show no workspace UI +- has one default project workspace and default execution mode: inherit silently + +### Show a `Workspace` section only when relevant + +#### Basic fields + +- `Codebase` + - default selected project workspace +- `Execution mode` + - `Project default` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + +#### Advanced-only field + +- `Reuse existing execution workspace` + +This dropdown should show only active execution workspaces for the selected project workspace, with labels like: + +- `dotta/integration-branch` +- `PAP-447-add-worktree-support` +- `shared primary workspace` + +### Important rule + +Do not show a picker containing every possible workspace object by default. + +The normal flow should feel like: + +- choose project +- optionally choose codebase +- optionally choose execution mode + +not: + +- choose from a long mixed list of roots, derived worktrees, previews, and branch names + +### Migration rule + +For existing users, issue creation should continue to look the same until a project explicitly enables richer workspace behavior. + +## 5. Issue Detail + +Issue detail should expose workspace and work product clearly, but without becoming a code host UI. + +### Header chips + +Show compact summary chips near the title/status area: + +- `Codebase: Web App` +- `Workspace: Shared` +- `Workspace: PAP-447-add-worktree-support` +- `PR: Open` +- `Preview: Healthy` + +### Tabs + +- `Comments` +- `Subissues` +- `Work Product` +- `Activity` + +### `Work Product` tab + +Sections: + +- `Current workspace` +- `Previews` +- `Pull requests` +- `Branches and commits` +- `Artifacts and documents` + +#### Current workspace panel + +Fields: + +- workspace name +- mode +- branch +- base ref +- last used +- linked issues count +- cleanup status + +Actions: + +- `Open workspace details` +- `Mark in review` +- `Request cleanup` + +#### Pull request cards + +Fields: + +- title +- provider +- status +- review state +- linked branch +- open/ready/merged timestamps + +Actions: + +- `Open PR` +- `Refresh status` +- `Request board review` + +#### Preview cards + +Fields: + +- title +- URL +- provider +- health +- ownership +- updated at + +Actions: + +- `Open preview` +- `Refresh` +- `Archive` + +## 6. Execution Workspace Detail + +This can be reached from a project code tab or an issue work product tab. + +It does not need to be in the main sidebar. + +### Sections + +- identity +- source issue +- linked issues +- branch/ref +- provider/session identity +- active runtime services +- previews +- PRs +- cleanup state +- event/activity history + +### Motivation + +This is where advanced users go when they need to inspect the mechanics. Most users should not need it in normal flow. + +## 7. Inbox Behavior + +Inbox should surface actionable work product events, not every implementation detail. + +### Show inbox items for + +- issue assigned or updated +- PR needs board review +- PR opened or marked ready +- preview unhealthy +- workspace cleanup failed +- runtime service failed +- remote cloud-agent run returned PR or preview that needs review + +### Do not show by default + +- every workspace heartbeat +- every branch update +- every derived workspace creation + +### Display style + +If the inbox item is about a preview or PR, show issue context with it: + +- issue identifier and title +- parent issue if this is a subissue +- workspace name if relevant + +## 8. Issues List and Kanban + +Keep list and board planning-first. + +### Default behavior + +- show top-level issues by default +- show parent rollups for subissues +- do not flatten every child execution detail into the main board + +### Row/card adornments + +For issues with linked work product, show compact badges: + +- `1 PR` +- `2 previews` +- `shared workspace` +- `isolated workspace` + +### Optional advanced filters + +- `Has PR` +- `Has preview` +- `Workspace mode` +- `Codebase` + +## Upgrade and Migration Plan + +## 1. Product-level migration stance + +Migration must be silent-by-default and compatibility-preserving. + +Existing users should not be forced to: + +- create new workspace objects by hand before they can keep working +- re-tag old issues +- learn new workspace concepts before basic issue flows continue to function + +## 2. Existing project migration + +On upgrade: + +- existing `project_workspaces` records are retained and shown as `Project Workspaces` +- the current primary workspace remains the default codebase +- existing project execution workspace policy is mapped into the new `Project Execution Workspace Policy` surface +- projects with no execution workspace policy stay in compatible/shared mode + +## 3. Existing issue migration + +On upgrade: + +- existing issues default to `executionWorkspacePreference=inherit` +- if an issue already has execution workspace settings, map them forward directly +- if an issue has no explicit workspace data, preserve existing behavior and do not force a user-visible choice + +## 4. Existing run/runtime migration + +On upgrade: + +- active or recent runtime services can be backfilled into execution workspace history where feasible +- missing history should not block rollout; forward correctness matters more than perfect historical reconstruction + +## 5. Rollout UX + +Use additive language in the UI: + +- `Code` +- `Workspace automation` +- `Optional` +- `Advanced` + +Avoid migration copy that implies users were previously using the product "wrong". + +## Cloud Deployment Requirements + +## 1. Paperclip host and execution host must be decoupled + +Paperclip may run: + +- locally with direct filesystem access +- in a cloud app host such as Vercel +- in a hybrid setup with external job runners + +The workspace model must work in all three. + +## 2. Remote execution must support first-class work product reporting + +A cloud agent should be able to: + +- resolve a project workspace +- realize an adapter-managed execution workspace remotely +- produce a branch +- open or update a PR +- emit preview URLs +- register artifacts + +without the Paperclip host itself running local git or local preview processes. + +## 3. Local-only assumptions must be optional + +The following must be optional, not required: + +- local `cwd` +- local git CLI +- host-managed worktree directories +- host-owned long-lived preview processes + +## 4. Same product surface, different provider behavior + +The UI should not split into "local mode" and "cloud mode" products. + +Instead: + +- local projects show path/git implementation details +- cloud projects show provider/reference details +- both surface the same high-level objects: + - project workspace + - execution workspace + - work product + - runtime service or preview + +## Behavior Rules + +## 1. Cleanup must not depend on agents remembering `in_review` + +Agents may still use `in_review`, but cleanup behavior must be governed by policy and observed state. + +### Keep an execution workspace alive while any of these are true + +- a linked issue is non-terminal +- a linked PR is open +- a linked preview/runtime service is active +- the workspace is still within retention window + +### Hide instead of deleting aggressively + +Archived or idle workspaces should be hidden from default lists before they are hard-cleaned up. + +## 2. Multiple issues may intentionally share one execution workspace + +This is how Paperclip supports: + +- solo dev on a shared branch +- operator integration branches +- related features batched into one PR + +This is the key reason not to force 1 issue = 1 workspace = 1 PR. + +## 3. Isolated issue workspaces remain opt-in + +Even in a git-heavy project, isolated workspaces should be optional. + +Examples where shared mode is valid: + +- tiny bug fixes +- branchless prototyping +- non-git projects +- single-user local workflows + +## 4. PR policy belongs to git-backed workspace policy + +PR automation decisions should be made at the project/workspace policy layer. + +The issue should only: + +- surface the resulting PR +- route approvals/review requests +- show status and review state + +## 5. Work product is the user-facing unifier + +Previews, PRs, commits, and artifacts should all be discoverable through one consistent issue-level affordance. + +That keeps Paperclip focused on coordination and visibility instead of splitting outputs across many hidden subsystems. + +## Recommended Implementation Order + +## Phase 1: Clarify current objects in UI + +1. Surface `Project > Code` tab +2. Show existing project workspaces there +3. Re-enable project-level execution workspace policy with revised copy +4. Keep issue creation simple with inherited defaults + +## Phase 2: Add explicit execution workspace record + +1. Add `execution_workspaces` +2. Link runs, issues, previews, and PRs to it +3. Add simple execution workspace detail page +4. Make `cwd` optional and ensure provider-managed remote workspaces are supported from day one + +## Phase 3: Add work product model + +1. Add `issue_work_products` +2. Ingest PRs, previews, branches, commits +3. Add issue `Work Product` tab +4. Add inbox items for actionable work product state changes +5. Support remote agent-created PR/preview reporting without local ownership + +## Phase 4: Add advanced reuse and cleanup workflows + +1. Add `reuse existing execution workspace` +2. Add cleanup lifecycle UI +3. Add operator branch workflow shortcuts +4. Add richer external preview harvesting +5. Add migration tooling/backfill where it improves continuity for existing users + +## Why This Model Is Right + +This model keeps the product balanced: + +- simple enough for solo users +- strong enough for real engineering teams +- flexible for non-git projects +- explicit enough to govern PRs and previews + +Most importantly, it keeps the abstractions clean: + +- projects plan the work +- project workspaces define the durable codebases +- execution workspaces define where work ran +- work product defines what came out of the work +- PRs remain outputs, not the core task model + +It also keeps the rollout practical: + +- existing users can upgrade without workflow breakage +- local-first installs stay simple +- cloud-hosted Paperclip deployments remain first-class + +That is a better fit for Paperclip than either extreme: + +- hiding workspace behavior until nobody understands it +- or making the whole app revolve around code-host mechanics diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md new file mode 100644 index 00000000..075156fd --- /dev/null +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -0,0 +1,155 @@ +# Plugin Authoring Guide + +This guide describes the current, implemented way to create a Paperclip plugin in this repo. + +It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now. + +## Current reality + +- Treat plugin workers and plugin UI as trusted code. +- Plugin UI runs as same-origin JavaScript inside the main Paperclip app. +- Worker-side host APIs are capability-gated. +- Plugin UI is not sandboxed by manifest capabilities. +- There is no host-provided shared React component kit for plugins yet. +- `ctx.assets` is not supported in the current runtime. + +## Scaffold a plugin + +Use the scaffold package: + +```bash +pnpm --filter @paperclipai/create-paperclip-plugin build +node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples +``` + +For a plugin that lives outside the Paperclip repo: + +```bash +pnpm --filter @paperclipai/create-paperclip-plugin build +node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \ + --output /absolute/path/to/plugin-repos \ + --sdk-path /absolute/path/to/paperclip/packages/plugins/sdk +``` + +That creates a package with: + +- `src/manifest.ts` +- `src/worker.ts` +- `src/ui/index.tsx` +- `tests/plugin.spec.ts` +- `esbuild.config.mjs` +- `rollup.config.mjs` + +Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`. + +Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. + +## Recommended local workflow + +From the generated plugin folder: + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm build +``` + +For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds. + +Example: + +```bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}' +``` + +## Supported alpha surface + +Worker: + +- config +- events +- jobs +- launchers +- http +- secrets +- activity +- state +- entities +- projects and project workspaces +- companies +- issues and comments +- agents and agent sessions +- goals +- data/actions +- streams +- tools +- metrics +- logger + +UI: + +- `usePluginData` +- `usePluginAction` +- `usePluginStream` +- `usePluginToast` +- `useHostContext` +- typed slot props from `@paperclipai/plugin-sdk/ui` + +Mount surfaces currently wired in the host include: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `sidebar` +- `sidebarPanel` +- `detailTab` +- `taskDetailView` +- `projectSidebarItem` +- `globalToolbarButton` +- `toolbarButton` +- `contextMenuItem` +- `commentAnnotation` +- `commentContextMenuItem` + +## Company routes + +Plugins may declare a `page` slot with `routePath` to own a company route like: + +```text +/:companyPrefix/ +``` + +Rules: + +- `routePath` must be a single lowercase slug +- it cannot collide with reserved host routes +- it cannot duplicate another installed plugin page route + +## Publishing guidance + +- Use npm packages as the deployment artifact. +- Treat repo-local example installs as a development workflow only. +- Prefer keeping plugin UI self-contained inside the package. +- Do not rely on host design-system components or undocumented app internals. +- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry. + +## Verification before handoff + +At minimum: + +```bash +pnpm --filter typecheck +pnpm --filter test +pnpm --filter build +``` + +If you changed host integration too, also run: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md new file mode 100644 index 00000000..f3ec6473 --- /dev/null +++ b/doc/plugins/PLUGIN_SPEC.md @@ -0,0 +1,1644 @@ +# Paperclip Plugin System Specification + +Status: proposed complete spec for the post-V1 plugin system + +This document is the complete specification for Paperclip's plugin and extension architecture. +It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be read alongside the comparative analysis in [doc/plugins/ideas-from-opencode.md](./ideas-from-opencode.md). + +This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). +It is the full target architecture for the plugin system that should follow V1. + +## Current implementation caveats + +The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec. + +Today, the practical deployment model is: + +- single-tenant +- self-hosted +- single-node or otherwise filesystem-persistent + +Current limitations to keep in mind: + +- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary. +- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly. +- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory. +- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry. +- Published npm packages are the intended install artifact for deployed plugins. +- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build. +- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet. +- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises. + +In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution. + +## 1. Scope + +This spec covers: + +- plugin packaging and installation +- runtime model +- trust model +- capability system +- UI extension surfaces +- plugin settings UI +- agent tool contributions +- event, job, and webhook surfaces +- plugin-to-plugin communication +- local tooling approach for workspace plugins +- Postgres persistence for extensions +- uninstall and data lifecycle +- plugin observability +- plugin development and testing +- operator workflows +- hot plugin lifecycle (no server restart) +- SDK versioning and compatibility rules + +This spec does not cover: + +- a public marketplace +- cloud/SaaS multi-tenancy +- arbitrary third-party schema migrations in the first plugin version +- iframe-sandboxed plugin UI in the first plugin version (plugins render as ES modules in host extension slots) + +## 2. Core Assumptions + +Paperclip plugin design is based on the following assumptions: + +1. Paperclip is single-tenant and self-hosted. +2. Plugin installation is global to the instance. +3. "Companies" remain core Paperclip business objects, but they are not plugin trust boundaries. +4. Board governance, approval gates, budget hard-stops, and core task invariants remain owned by Paperclip core. +5. Projects already have a real workspace model via `project_workspaces`, and local/runtime plugins should build on that instead of inventing a separate workspace abstraction. + +## 3. Goals + +The plugin system must: + +1. Let operators install global instance-wide plugins. +2. Let plugins add major capabilities without editing Paperclip core. +3. Keep core governance and auditing intact. +4. Support both local/runtime plugins and external SaaS connectors. +5. Support future plugin categories such as: + - new agent adapters + - revenue tracking + - knowledge base + - issue tracker sync + - metrics/dashboards + - file/project tooling +6. Use simple, explicit, typed contracts. +7. Keep failures isolated so one plugin does not crash the entire instance. + +## 4. Non-Goals + +The first plugin system must not: + +1. Allow arbitrary plugins to override core routes or core invariants. +2. Allow arbitrary plugins to mutate approval, auth, issue checkout, or budget enforcement logic. +3. Allow arbitrary third-party plugins to run free-form DB migrations. +4. Depend on project-local plugin folders such as `.paperclip/plugins`. +5. Depend on automatic install-and-execute behavior at server startup from arbitrary config files. + +## 5. Terminology + +### 5.1 Instance + +The single Paperclip deployment an operator installs and controls. + +### 5.2 Company + +A first-class Paperclip business object inside the instance. + +### 5.3 Project Workspace + +A workspace attached to a project through `project_workspaces`. +Plugins resolve workspace paths from this model to locate local directories for file, terminal, git, and process operations. + +### 5.4 Platform Module + +A trusted in-process extension loaded directly by Paperclip core. + +Examples: + +- agent adapters +- storage providers +- secret providers +- run-log backends + +### 5.5 Plugin + +An installable instance-wide extension package loaded through the Paperclip plugin runtime. + +Examples: + +- Linear sync +- GitHub Issues sync +- Grafana widgets +- Stripe revenue sync +- file browser +- terminal +- git workflow + +### 5.6 Plugin Worker + +The runtime process used for a plugin. +In this spec, third-party plugins run out-of-process by default. + +### 5.7 Capability + +A named permission the host grants to a plugin. +Plugins may only call host APIs that are covered by granted capabilities. + +## 6. Extension Classes + +Paperclip has two extension classes. + +## 6.1 Platform Modules + +Platform modules are: + +- trusted +- in-process +- host-integrated +- low-level + +They use explicit registries, not the general plugin worker protocol. + +Platform module surfaces: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +Platform modules are the right place for: + +- new agent adapter packages +- new storage backends +- new secret backends +- other host-internal systems that need direct process or DB integration + +## 6.2 Plugins + +Plugins are: + +- globally installed per instance +- loaded through the plugin runtime +- additive +- capability-gated +- isolated from core via a stable SDK and host protocol + +Plugin categories: + +- `connector` +- `workspace` +- `automation` +- `ui` + +A plugin may declare more than one category. + +## 7. Project Workspaces + +Paperclip already has a concrete workspace model: + +- projects expose `workspaces` +- projects expose `primaryWorkspace` +- the database contains `project_workspaces` +- project routes already manage workspaces + +Plugins that need local tooling (file browsing, git, terminals, process tracking) can resolve workspace paths through the project workspace APIs and then operate on the filesystem, spawn processes, and run git commands directly. The host does not wrap these operations — plugins own their own implementations. + +## 8. Installation Model + +Plugin installation is global and operator-driven. + +There is no per-company install table and no per-company enable/disable switch. + +If a plugin needs business-object-specific mappings, those are stored as plugin configuration or plugin state. + +Examples: + +- one global Linear plugin install +- mappings from company A to Linear team X and company B to Linear team Y +- one global git plugin install +- per-project workspace state stored under `project_workspace` + +## 8.1 On-Disk Layout + +Plugins live under the Paperclip instance directory. + +Suggested layout: + +- `~/.paperclip/instances/default/plugins/package.json` +- `~/.paperclip/instances/default/plugins/node_modules/` +- `~/.paperclip/instances/default/plugins/.cache/` +- `~/.paperclip/instances/default/data/plugins//` + +The package install directory and the plugin data directory are separate. + +This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work. + +## 8.2 Operator Commands + +Paperclip should add CLI commands: + +- `pnpm paperclipai plugin list` +- `pnpm paperclipai plugin install ` +- `pnpm paperclipai plugin uninstall ` +- `pnpm paperclipai plugin upgrade [version]` +- `pnpm paperclipai plugin doctor ` + +These commands are instance-level operations. + +## 8.3 Install Process + +The install process is: + +1. Resolve npm package and version. +2. Install into the instance plugin directory. +3. Read and validate plugin manifest. +4. Reject incompatible plugin API versions. +5. Display requested capabilities to the operator. +6. Persist install record in Postgres. +7. Start plugin worker and run health/validation. +8. Mark plugin `ready` or `error`. + +For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added. + +## 9. Load Order And Precedence + +Load order must be deterministic. + +1. core platform modules +2. built-in first-party plugins +3. installed plugins sorted by: + - explicit operator-configured order if present + - otherwise manifest `id` + +Rules: + +- plugin contributions are additive by default +- plugins may not override core routes or core actions by name collision +- UI slot IDs are automatically namespaced by plugin ID (e.g. `@paperclip/plugin-linear:sync-health-widget`), so cross-plugin collisions are structurally impossible +- if a single plugin declares duplicate slot IDs within its own manifest, the host must reject at install time + +## 10. Package Contract + +Each plugin package must export a manifest, a worker entrypoint, and optionally a UI bundle. + +Suggested package layout: + +- `dist/manifest.js` +- `dist/worker.js` +- `dist/ui/` (optional, contains the plugin's frontend bundle) + +Suggested `package.json` keys: + +```json +{ + "name": "@paperclip/plugin-linear", + "version": "0.1.0", + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + } +} +``` + +## 10.1 Manifest Shape + +Normative manifest shape: + +```ts +export interface PaperclipPluginManifestV1 { + id: string; + apiVersion: 1; + version: string; + displayName: string; + description: string; + categories: Array<"connector" | "workspace" | "automation" | "ui">; + minimumPaperclipVersion?: string; + capabilities: string[]; + entrypoints: { + worker: string; + ui?: string; + }; + instanceConfigSchema?: JsonSchema; + jobs?: PluginJobDeclaration[]; + webhooks?: PluginWebhookDeclaration[]; + tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; + }>; + ui?: { + slots: Array<{ + type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage"; + 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">; + }>; + }; +} +``` + +Rules: + +- `id` must be globally unique +- `id` should normally equal the npm package name +- `apiVersion` must match the host-supported plugin API version +- `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 + +## 11. Agent Tools + +Plugins may contribute tools that Paperclip agents can use during runs. + +### 11.1 Tool Declaration + +Plugins declare tools in their manifest: + +```ts +tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; +}>; +``` + +Tool names are automatically namespaced by plugin ID at runtime (e.g. `linear:search-issues`), so plugins cannot shadow core tools or each other's tools. + +### 11.2 Tool Execution + +When an agent invokes a plugin tool during a run, the host routes the call to the plugin worker via a `executeTool` RPC method: + +- `executeTool(input)` — receives tool name, parsed parameters, and run context (agent ID, run ID, company ID, project ID) + +The worker executes the tool logic and returns a typed result. The host enforces capability gates — a plugin must declare `agent.tools.register` to contribute tools, and individual tools may require additional capabilities (e.g. `http.outbound` for tools that call external APIs). + +### 11.3 Tool Availability + +By default, plugin tools are available to all agents. The operator may restrict tool availability per agent or per project through plugin configuration. + +Plugin tools appear in the agent's tool list alongside core tools but are visually distinguished in the UI as plugin-contributed. + +### 11.4 Constraints + +- Plugin tools must not override or shadow core tools by name. +- Plugin tools must be idempotent where possible. +- Tool execution is subject to the same timeout and resource limits as other plugin worker calls. +- Tool results are included in run logs. + +## 12. Runtime Model + +## 12.1 Process Model + +Third-party plugins run out-of-process by default. + +Default runtime: + +- Paperclip server starts one worker process per installed plugin +- the worker process is a Node process +- host and worker communicate over JSON-RPC on stdio + +This design provides: + +- failure isolation +- clearer logging boundaries +- easier resource limits +- a cleaner trust boundary than arbitrary in-process execution + +## 12.2 Host Responsibilities + +The host is responsible for: + +- package install +- manifest validation +- capability enforcement +- process supervision +- job scheduling +- webhook routing +- activity log writes +- secret resolution +- UI route registration + +## 12.3 Worker Responsibilities + +The plugin worker is responsible for: + +- validating its own config +- handling domain events +- handling scheduled jobs +- handling webhooks +- serving data and handling actions for the plugin's own UI via `getData` and `performAction` +- invoking host services through the SDK +- reporting health information + +## 12.4 Failure Policy + +If a worker fails: + +- mark plugin status `error` +- surface error in plugin health UI +- keep the rest of the instance running +- retry start with bounded backoff +- do not drop other plugins or core services + +## 12.5 Graceful Shutdown Policy + +When the host needs to stop a plugin worker (for upgrade, uninstall, or instance shutdown): + +1. The host sends `shutdown()` to the worker. +2. The worker has 10 seconds to finish in-flight work and exit cleanly. +3. If the worker does not exit within the deadline, the host sends SIGTERM. +4. If the worker does not exit within 5 seconds after SIGTERM, the host sends SIGKILL. +5. Any in-flight job runs are marked `cancelled` with a note indicating forced shutdown. +6. Any in-flight `getData` or `performAction` calls return an error to the bridge. + +The shutdown deadline should be configurable per-plugin in plugin config for plugins that need longer drain periods. + +## 13. Host-Worker Protocol + +The host must support the following worker RPC methods. + +Required methods: + +- `initialize(input)` +- `health()` +- `shutdown()` + +Optional methods: + +- `validateConfig(input)` +- `configChanged(input)` +- `onEvent(input)` +- `runJob(input)` +- `handleWebhook(input)` +- `getData(input)` +- `performAction(input)` +- `executeTool(input)` + +### 13.1 `initialize` + +Called once on worker startup. + +Input includes: + +- plugin manifest +- resolved plugin config +- instance info +- host API version + +### 13.2 `health` + +Returns: + +- status +- current error if any +- optional plugin-reported diagnostics + +### 13.3 `validateConfig` + +Runs after config changes and startup. + +Returns: + +- `ok` +- warnings +- errors + +### 13.4 `configChanged` + +Called when the operator updates the plugin's instance config at runtime. + +Input includes: + +- new resolved config + +If the worker implements this method, it applies the new config without restarting. If the worker does not implement this method, the host restarts the worker process with the new config (graceful shutdown then restart). + +### 13.5 `onEvent` + +Receives one typed Paperclip domain event. + +Delivery semantics: + +- at least once +- plugin must be idempotent +- no global ordering guarantee across all event types +- per-entity ordering is best effort but not guaranteed after retries + +### 13.6 `runJob` + +Runs a declared scheduled job. + +The host provides: + +- job key +- trigger source +- run id +- schedule metadata + +### 13.7 `handleWebhook` + +Receives inbound webhook payload routed by the host. + +The host provides: + +- endpoint key +- headers +- raw body +- parsed body if applicable +- request id + +### 13.8 `getData` + +Returns plugin data requested by the plugin's own UI components. + +The plugin UI calls the host bridge, which forwards the request to the worker. The worker returns typed JSON that the plugin's own frontend components render. + +Input includes: + +- data key (plugin-defined, e.g. `"sync-health"`, `"issue-detail"`) +- context (company id, project id, entity id, etc.) +- optional query parameters + +### 13.9 `performAction` + +Runs an explicit plugin action initiated by the board UI. + +Examples: + +- "resync now" +- "link GitHub issue" +- "create branch from issue" +- "restart process" + +### 13.10 `executeTool` + +Runs a plugin-contributed agent tool during a run. + +The host provides: + +- tool name (without plugin namespace prefix) +- parsed parameters matching the tool's declared schema +- run context: agent ID, run ID, company ID, project ID + +The worker executes the tool and returns a typed result (string content, structured data, or error). + +## 14. SDK Surface + +Plugins do not talk to the DB directly. +Plugins do not read raw secret material from persisted config. + +The SDK exposed to workers must provide typed host clients. + +Required SDK clients: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.data` +- `ctx.actions` +- `ctx.tools` +- `ctx.logger` + +`ctx.data` and `ctx.actions` register handlers that the plugin's own UI calls through the host bridge. `ctx.data.register(key, handler)` backs `usePluginData(key)` on the frontend. `ctx.actions.register(key, handler)` backs `usePluginAction(key)`. + +Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations. + +## 14.1 Example SDK Shape + +```ts +/** Top-level helper for defining a plugin with type checking */ +export function definePlugin(definition: PluginDefinition): PaperclipPlugin; + +/** Re-exported from Zod for config schema definitions */ +export { z } from "zod"; + +export interface PluginContext { + manifest: PaperclipPluginManifestV1; + config: { + get(): Promise>; + }; + events: { + on(name: string, fn: (event: unknown) => Promise): void; + on(name: string, filter: EventFilter, fn: (event: unknown) => Promise): void; + emit(name: string, payload: unknown): Promise; + }; + jobs: { + register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise): void; + }; + state: { + get(input: ScopeKey): Promise; + set(input: ScopeKey, value: unknown): Promise; + delete(input: ScopeKey): Promise; + }; + entities: { + upsert(input: PluginEntityUpsert): Promise; + list(input: PluginEntityQuery): Promise; + }; + data: { + register(key: string, handler: (params: Record) => Promise): void; + }; + actions: { + register(key: string, handler: (params: Record) => Promise): void; + }; + tools: { + register(name: string, input: PluginToolDeclaration, fn: (params: unknown, runCtx: ToolRunContext) => Promise): void; + }; + logger: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; + debug(message: string, meta?: Record): void; + }; +} + +export interface EventFilter { + projectId?: string; + companyId?: string; + agentId?: string; + [key: string]: unknown; +} +``` + +## 15. Capability Model + +Capabilities are mandatory and static. +Every plugin declares them up front. + +The host enforces capabilities in the SDK layer and refuses calls outside the granted set. + +## 15.1 Capability Categories + +### Data Read + +- `companies.read` +- `projects.read` +- `project.workspaces.read` +- `issues.read` +- `issue.comments.read` +- `agents.read` +- `goals.read` +- `activity.read` +- `costs.read` + +### Data Write + +- `issues.create` +- `issues.update` +- `issue.comments.create` +- `assets.write` +- `assets.read` +- `activity.log.write` +- `metrics.write` + +### Plugin State + +- `plugin.state.read` +- `plugin.state.write` + +### Runtime / Integration + +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` + +### Agent Tools + +- `agent.tools.register` + +### UI + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` +- `ui.dashboardWidget.register` +- `ui.action.register` + +## 15.2 Forbidden Capabilities + +The host must not expose capabilities for: + +- approval decisions +- budget override +- auth bypass +- issue checkout lock override +- direct DB access + +## 15.3 Upgrade Rules + +If a plugin upgrade adds capabilities: + +1. the host must mark the plugin `upgrade_pending` +2. the operator must explicitly approve the new capability set +3. the new version does not become `ready` until approval completes + +## 16. Event System + +The host must emit typed domain events that plugins may subscribe to. + +Minimum event set: + +- `company.created` +- `company.updated` +- `project.created` +- `project.updated` +- `project.workspace_created` +- `project.workspace_updated` +- `project.workspace_deleted` +- `issue.created` +- `issue.updated` +- `issue.comment.created` +- `agent.created` +- `agent.updated` +- `agent.status_changed` +- `agent.run.started` +- `agent.run.finished` +- `agent.run.failed` +- `agent.run.cancelled` +- `approval.created` +- `approval.decided` +- `cost_event.created` +- `activity.logged` + +Each event must include: + +- event id +- event type +- occurred at +- actor metadata when applicable +- primary entity metadata +- typed payload + +### 16.1 Event Filtering + +Plugins may provide an optional filter when subscribing to events. The filter is evaluated by the host before dispatching to the worker, so filtered-out events never cross the process boundary. + +Supported filter fields: + +- `projectId` — only receive events for a specific project +- `companyId` — only receive events for a specific company +- `agentId` — only receive events for a specific agent + +Filters are optional. If omitted, the plugin receives all events of the subscribed type. Filters may be combined (e.g. filter by both company and project). + +### 16.2 Plugin-to-Plugin Events + +Plugins may emit custom events using `ctx.events.emit(name, payload)`. Plugin-emitted events use a namespaced event type: `plugin..`. + +Other plugins may subscribe to these events using the same `ctx.events.on()` API: + +```ts +ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push +}); +``` + +Rules: + +- Plugin events require the `events.emit` capability. +- Plugin events are not core domain events — they do not appear in the core activity log unless the emitting plugin explicitly logs them. +- Plugin events follow the same at-least-once delivery semantics as core events. +- The host must not allow plugins to emit events in the core namespace (events without the `plugin.` prefix). + +## 17. Scheduled Jobs + +Plugins may declare scheduled jobs in their manifest. + +Job rules: + +1. Each job has a stable `job_key`. +2. The host is the scheduler of record. +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. + +## 18. Webhooks + +Plugins may declare webhook endpoints in their manifest. + +Webhook route shape: + +- `POST /api/plugins/:pluginId/webhooks/:endpointKey` + +Rules: + +1. The host owns the public route. +2. The worker receives the request body through `handleWebhook`. +3. Signature verification happens in plugin code using secret refs resolved by the host. +4. Every delivery is recorded. +5. Webhook handling must be idempotent. + +## 19. UI Extension Model + +Plugins ship their own frontend UI as a bundled React module. The host loads plugin UI into designated extension slots and provides a bridge for the plugin frontend to communicate with its own worker backend and with host APIs. + +### How Plugin UI Publishing Works In Practice + +A plugin's `dist/ui/` directory contains a built React bundle. The host serves this bundle and loads it into the page when the user navigates to a plugin surface (a plugin page, a detail tab, a dashboard widget, etc.). + +**The host provides, the plugin renders:** + +1. The host defines **extension slots** — designated mount points in the UI where plugin components can appear (pages, tabs, widgets, sidebar entries, action bars). +2. The plugin's UI bundle exports named components for each slot it wants to fill. +3. The host mounts the plugin component into the slot, passing it a **host bridge** object. +4. The plugin component uses the bridge to fetch data from its own worker (via `getData`), call actions (via `performAction`), read host context (current company, project, entity), and use shared host UI primitives (design tokens, common components). + +**Concrete example: a Linear plugin ships a dashboard widget.** + +The plugin's UI bundle exports: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, StatusBadge } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + + return ( +
+ + {data.mappings.map(m => ( + + ))} + +
+ ); +} +``` + +**What happens at runtime:** + +1. User opens the dashboard. The host sees that the Linear plugin registered a `DashboardWidget` export. +2. The host mounts the plugin's `DashboardWidget` component into the dashboard widget slot, passing `context` (current company, user, etc.) and the bridge. +3. `usePluginData("sync-health", ...)` calls through the bridge → host → plugin worker's `getData` RPC → returns JSON → the plugin component renders it however it wants. +4. When the user clicks "Resync Now", `usePluginAction("resync")` calls through the bridge → host → plugin worker's `performAction` RPC. + +**What the host controls:** + +- The host decides **where** plugin components appear (which slots exist and when they mount). +- The host provides the **bridge** — plugin UI cannot make arbitrary network requests or access host internals directly. +- The host enforces **capability gates** — if a plugin's worker does not have a capability, the bridge rejects the call even if the UI requests it. +- The host provides **design tokens and shared components** via `@paperclipai/plugin-sdk/ui` so plugins can match the host's visual language without being forced to. + +**What the plugin controls:** + +- The plugin decides **how** to render its data — it owns its React components, layout, interactions, and state management. +- The plugin decides **what data** to fetch and **what actions** to expose. +- The plugin can use any React patterns (hooks, context, third-party component libraries) inside its bundle. + +### 19.0.1 Plugin UI SDK (`@paperclipai/plugin-sdk/ui`) + +The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides: + +- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()` +- **Design tokens**: colors, spacing, typography, shadows matching the host theme +- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc. +- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps` + +Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge. + +### 19.0.2 Bundle Isolation + +Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens. + +Isolation rules: + +- Plugin bundles must not import from host internals. They may only import from `@paperclipai/plugin-sdk/ui` and their own dependencies. +- Plugin bundles must not access `window.fetch` or `XMLHttpRequest` directly for host API calls. All host communication goes through the bridge. +- The host may enforce Content Security Policy rules that restrict plugin network access to the bridge endpoint only. +- Plugin bundles must be statically analyzable — no dynamic `import()` of URLs outside the plugin's own bundle. + +If stronger isolation is needed later, the host can move to iframe-based mounting for untrusted plugins without changing the plugin's source code (the bridge API stays the same). + +### 19.0.3 Bundle Serving + +Plugin UI bundles must be pre-built ESM. The host does not compile or transform plugin UI code at runtime. + +The host serves the plugin's `dist/ui/` directory as static assets under a namespaced path: + +- `/_plugins/:pluginId/ui/*` + +When the host renders an extension slot, it dynamically imports the plugin's UI entry module from this path, resolves the named export declared in `ui.slots[].exportName`, and mounts it into the slot. + +In development, the host may support a `devUiUrl` override in plugin config that points to a local dev server (e.g. Vite) so plugin authors can use hot-reload during development without rebuilding. + +## 19.1 Global Operator Routes + +- `/settings/plugins` +- `/settings/plugins/:pluginId` + +These routes are instance-level. + +## 19.2 Company-Context Routes + +- `/:companyPrefix/plugins/:pluginId` + +These routes exist because the board UI is organized around companies even though plugin installation is global. + +## 19.3 Detail Tabs + +Plugins may add tabs to: + +- project detail +- issue detail +- agent detail +- goal detail +- run detail + +Recommended route pattern: + +- `/:companyPrefix//:id?tab=` + +## 19.4 Dashboard Widgets + +Plugins may add cards or sections to the dashboard. + +## 19.5 Sidebar Entries + +Plugins may add sidebar links to: + +- global plugin settings +- company-context plugin pages + +## 19.6 Shared Components In `@paperclipai/plugin-sdk/ui` + +The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement. + +| Component | What it renders | Typical use | +|---|---|---| +| `MetricCard` | Single number with label, optional trend/sparkline | KPIs, counts, rates | +| `StatusBadge` | Inline status indicator (ok/warning/error/info) | Sync health, connection status | +| `DataTable` | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists | +| `TimeseriesChart` | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates | +| `MarkdownBlock` | Rendered markdown text | Descriptions, help text, notes | +| `KeyValueList` | Label/value pairs in a definition-list layout | Entity metadata, config summary | +| `ActionBar` | Row of buttons wired to `usePluginAction` | Resync, create branch, restart process | +| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | +| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | +| `Spinner` | Loading indicator | Data fetch states | + +Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render. + +## 19.7 Error Propagation Through The Bridge + +The bridge hooks must return structured errors so plugin UI can handle failures gracefully. + +`usePluginData` returns: + +```ts +{ + data: T | null; + loading: boolean; + error: PluginBridgeError | null; +} +``` + +`usePluginAction` returns an async function that either resolves with the result or throws a `PluginBridgeError`. + +`PluginBridgeError` shape: + +```ts +interface PluginBridgeError { + code: "WORKER_UNAVAILABLE" | "CAPABILITY_DENIED" | "WORKER_ERROR" | "TIMEOUT" | "UNKNOWN"; + message: string; + /** Original error details from the worker, if available */ + details?: unknown; +} +``` + +Error codes: + +- `WORKER_UNAVAILABLE` — the plugin worker is not running (crashed, shutting down, not yet started) +- `CAPABILITY_DENIED` — the plugin does not have the required capability for this operation +- `WORKER_ERROR` — the worker returned an error from its `getData` or `performAction` handler +- `TIMEOUT` — the worker did not respond within the configured timeout +- `UNKNOWN` — unexpected bridge-level failure + +The `@paperclipai/plugin-sdk/ui` subpath should also export an `ErrorBoundary` component that plugin authors can use to catch rendering errors without crashing the host page. + +## 19.8 Plugin Settings UI + +Each plugin that declares an `instanceConfigSchema` in its manifest gets an auto-generated settings form at `/settings/plugins/:pluginId`. The host renders the form from the JSON Schema. + +The auto-generated form supports: + +- text inputs, number inputs, toggles, select dropdowns derived from schema types and enums +- nested objects rendered as fieldsets +- arrays rendered as repeatable field groups with add/remove controls +- secret ref fields: any schema property annotated with `"format": "secret-ref"` renders as a secret picker that resolves through the Paperclip secret provider system rather than a plain text input +- validation messages derived from schema constraints (`required`, `minLength`, `pattern`, `minimum`, etc.) +- a "Test Connection" action if the plugin declares a `validateConfig` RPC method — the host calls it and displays the result inline + +For plugins that need richer settings UX beyond what JSON Schema can express, the plugin may declare a `settingsPage` slot in `ui.slots`. When present, the host renders the plugin's own React component instead of the auto-generated form. The plugin component communicates with its worker through the standard bridge to read and write config. + +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. + +## 20. Local Tooling + +Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. + +The host provides workspace metadata through `ctx.projects` (list workspaces, get primary workspace, resolve workspace from issue or agent/run). Plugins use this metadata to resolve local paths and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +This keeps the host lean — it does not need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own logic for file browsing, git workflows, terminal sessions, and process management. + +## 21. Persistence And Postgres + +## 21.1 Database Principles + +1. Core Paperclip data stays in first-party tables. +2. Most plugin-owned data starts in generic extension tables. +3. Plugin data should scope to existing Paperclip objects before new tables are introduced. +4. Arbitrary third-party schema migrations are out of scope for the first plugin system. + +## 21.2 Core Table Reuse + +If data becomes part of the actual Paperclip product model, it should become a first-party table. + +Examples: + +- `project_workspaces` is already first-party +- if Paperclip later decides git state is core product data, it should become a first-party table too + +## 21.3 Required Tables + +### `plugins` + +- `id` uuid pk +- `plugin_key` text unique not null +- `package_name` text not null +- `version` text not null +- `api_version` int not null +- `categories` text[] not null +- `manifest_json` jsonb not null +- `status` enum: `installed | ready | error | upgrade_pending` +- `install_order` int null +- `installed_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +Indexes: + +- unique `plugin_key` +- `status` + +### `plugin_config` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` unique not null +- `config_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +### `plugin_state` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum: `instance | company | project | project_workspace | agent | issue | goal | run` +- `scope_id` uuid/text null +- `namespace` text not null +- `state_key` text not null +- `value_json` jsonb not null +- `updated_at` timestamptz not null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, namespace, state_key)` + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +### `plugin_jobs` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `job_key` text not null +- `schedule` text null +- `status` enum: `idle | queued | running | error` +- `next_run_at` timestamptz null +- `last_started_at` timestamptz null +- `last_finished_at` timestamptz null +- `last_succeeded_at` timestamptz null +- `last_error` text null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, job_key)` + +### `plugin_job_runs` + +- `id` uuid pk +- `plugin_job_id` uuid fk `plugin_jobs.id` not null +- `plugin_id` uuid fk `plugins.id` not null +- `status` enum: `queued | running | succeeded | failed | cancelled` +- `trigger` enum: `schedule | manual | retry` +- `started_at` timestamptz null +- `finished_at` timestamptz null +- `error` text null +- `details_json` jsonb null + +Indexes: + +- `(plugin_id, started_at desc)` +- `(plugin_job_id, started_at desc)` + +### `plugin_webhook_deliveries` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `endpoint_key` text not null +- `status` enum: `received | processed | failed | ignored` +- `request_id` text null +- `headers_json` jsonb null +- `body_json` jsonb null +- `received_at` timestamptz not null +- `handled_at` timestamptz null +- `response_code` int null +- `error` text null + +Indexes: + +- `(plugin_id, received_at desc)` +- `(plugin_id, endpoint_key, received_at desc)` + +### `plugin_entities` (optional but recommended) + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `entity_type` text not null +- `scope_kind` enum not null +- `scope_id` uuid/text null +- `external_id` text null +- `title` text null +- `status` text null +- `data_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null + +Indexes: + +- `(plugin_id, entity_type, external_id)` unique when `external_id` is not null +- `(plugin_id, scope_kind, scope_id, entity_type)` + +Use cases: + +- imported Linear issues +- imported GitHub issues +- plugin-owned process records +- plugin-owned external metric bindings + +## 21.4 Activity Log Changes + +The activity log should extend `actor_type` to include `plugin`. + +New actor enum: + +- `agent` +- `user` +- `system` +- `plugin` + +Plugin-originated mutations should write: + +- `actor_type = plugin` +- `actor_id = ` + +## 21.5 Plugin Migrations + +The first plugin system does not allow arbitrary third-party migrations. + +Later, if custom tables become necessary, the system may add a trusted-module-only migration path. + +## 22. Secrets + +Plugin config must never persist raw secret values. + +Rules: + +1. Plugin config stores secret refs only. +2. Secret refs resolve through the existing Paperclip secret provider system. +3. Plugin workers receive resolved secrets only at execution time. +4. Secret values must never be written to: + - plugin config JSON + - activity logs + - webhook delivery rows + - error messages + +## 23. Auditing + +All plugin-originated mutating actions must be auditable. + +Minimum requirements: + +- activity log entry for every mutation +- job run history +- webhook delivery history +- plugin health page +- install/upgrade history in `plugins` + +## 24. Operator UX + +## 24.1 Global Settings + +Global plugin settings page must show: + +- installed plugins +- versions +- status +- requested capabilities +- current errors +- install/upgrade/remove actions + +## 24.2 Plugin Settings Page + +Each plugin may expose: + +- config form derived from `instanceConfigSchema` +- health details +- recent job history +- recent webhook history +- capability list + +Route: + +- `/settings/plugins/:pluginId` + +## 24.3 Company-Context Plugin Page + +Each plugin may expose a company-context main page: + +- `/:companyPrefix/plugins/:pluginId` + +This page is where board users do most day-to-day work. + +## 25. Uninstall And Data Lifecycle + +When a plugin is uninstalled, the host must handle plugin-owned data explicitly. + +### 25.1 Uninstall Process + +1. The host sends `shutdown()` to the worker and follows the graceful shutdown policy. +2. The host marks the plugin status `uninstalled` in the `plugins` table (soft delete). +3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days). +4. During the grace period, the operator can reinstall the same plugin and recover its state. +5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin. +6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge `. + +### 25.2 Upgrade Data Considerations + +Plugin upgrades do not automatically migrate plugin state. If a plugin's `value_json` shape changes between versions: + +- The plugin worker is responsible for migrating its own state on first access after upgrade. +- The host does not run plugin-defined schema migrations. +- Plugins should version their state keys or use a schema version field inside `value_json` to detect and handle format changes. + +### 25.3 Upgrade Lifecycle + +When upgrading a plugin: + +1. The host sends `shutdown()` to the old worker. +2. The host waits for the old worker to drain in-flight work (respecting the shutdown deadline). +3. Any in-flight jobs that do not complete within the deadline are marked `cancelled`. +4. The host installs the new version and starts the new worker. +5. If the new version adds capabilities, the plugin enters `upgrade_pending` and the operator must approve before the new worker becomes `ready`. + +### 25.4 Hot Plugin Lifecycle + +Plugin install, uninstall, upgrade, and config changes **must** take effect without restarting the Paperclip server. This is a normative requirement, not optional. + +The architecture already supports this — plugins run as out-of-process workers with dynamic ESM imports, IPC bridges, and host-managed routing tables. This section makes the requirement explicit so implementations do not regress. + +#### 25.4.1 Hot Install + +When a plugin is installed at runtime: + +1. The host resolves and validates the manifest without stopping existing services. +2. The host spawns a new worker process for the plugin. +3. The host registers the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations in the live routing tables. +4. The host loads the plugin's UI bundle path into the extension slot registry so the frontend can discover it on the next navigation or via a live notification. +5. The plugin enters `ready` status (or `upgrade_pending` if capability approval is required). + +No other plugin or host service is interrupted. + +#### 25.4.2 Hot Uninstall + +When a plugin is uninstalled at runtime: + +1. The host sends `shutdown()` and follows the graceful shutdown policy (Section 12.5). +2. The host removes the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations from the live routing tables. +3. The host removes the plugin's UI bundle from the extension slot registry. Any currently mounted plugin UI components are unmounted and replaced with a placeholder or removed entirely. +4. The host marks the plugin `uninstalled` and starts the data retention grace period (Section 25.1). + +No server restart is needed. + +#### 25.4.3 Hot Upgrade + +When a plugin is upgraded at runtime: + +1. The host follows the upgrade lifecycle (Section 25.3) — shut down old worker, start new worker. +2. If the new version changes event subscriptions, job schedules, webhook endpoints, or agent tools, the host atomically swaps the old registrations for the new ones. +3. If the new version ships an updated UI bundle, the host invalidates any cached bundle assets and notifies the frontend to reload plugin UI components. Active users see the updated UI on next navigation or via a live refresh notification. +4. If the manifest `apiVersion` is unchanged and no new capabilities are added, the upgrade completes without operator interaction. + +#### 25.4.4 Hot Config Change + +When an operator updates a plugin's instance config at runtime: + +1. The host writes the new config to `plugin_config`. +2. The host sends a `configChanged` notification to the running worker via IPC. +3. The worker receives the new config through `ctx.config` and applies it without restarting. If the plugin needs to re-initialize connections (e.g. a new API token), it does so internally. +4. If the plugin does not handle `configChanged`, the host restarts the worker process with the new config (graceful shutdown then restart). + +#### 25.4.5 Frontend Cache Invalidation + +The host must version plugin UI bundle URLs (e.g. `/_plugins/:pluginId/ui/:version/*` or content-hash-based paths) so that browser caches do not serve stale bundles after upgrade or reinstall. + +The host should emit a `plugin.ui.updated` event that the frontend listens for to trigger re-import of updated plugin modules without a full page reload. + +#### 25.4.6 Worker Process Management + +The host's plugin process manager must support: + +- starting a worker for a newly installed plugin without affecting other workers +- stopping a worker for an uninstalled plugin without affecting other workers +- replacing a worker during upgrade (stop old, start new) atomically from the routing table's perspective +- restarting a worker after crash without operator intervention (with backoff) + +Each worker process is independent. There is no shared process pool or batch restart mechanism. + +## 26. Plugin Observability + +### 26.1 Logging + +Plugin workers use `ctx.logger` to emit structured logs. The host captures these logs and stores them in a queryable format. + +Log storage rules: + +- Plugin logs are stored in a `plugin_logs` table or appended to a log file under the plugin's data directory. +- Each log entry includes: plugin ID, timestamp, level, message, and optional structured metadata. +- Logs are queryable from the plugin settings page in the UI. +- Logs have a configurable retention period (default: 7 days). +- The host captures `stdout` and `stderr` from the worker process as fallback logs even if the worker does not use `ctx.logger`. + +### 26.2 Health Dashboard + +The plugin settings page must show: + +- current worker status (running, error, stopped) +- uptime since last restart +- recent log entries +- job run history with success/failure rates +- webhook delivery history with success/failure rates +- last health check result and diagnostics +- resource usage if available (memory, CPU) + +### 26.3 Alerting + +The host should emit internal events when plugin health degrades. These use the `plugin.*` namespace (not core domain events) and do not appear in the core activity log: + +- `plugin.health.degraded` — worker reporting errors or failing health checks +- `plugin.health.recovered` — worker recovered from error state +- `plugin.worker.crashed` — worker process exited unexpectedly +- `plugin.worker.restarted` — worker restarted after crash + +These events can be consumed by other plugins (e.g. a notification plugin) or surfaced in the dashboard. + +## 27. Plugin Development And Testing + +### 27.1 `@paperclipai/plugin-test-harness` + +The host should publish a test harness package that plugin authors use for local development and testing. + +The test harness provides: + +- a mock host that implements the full SDK interface (`ctx.config`, `ctx.events`, `ctx.state`, etc.) +- ability to send synthetic events and verify handler responses +- ability to trigger job runs and verify side effects +- ability to simulate `getData` and `performAction` calls as if coming from the UI bridge +- ability to simulate `executeTool` calls as if coming from an agent run +- in-memory state and entity stores for assertions +- configurable capability sets for testing capability denial paths + +Example usage: + +```ts +import { createTestHarness } from "@paperclipai/plugin-test-harness"; +import manifest from "../dist/manifest.js"; +import { register } from "../dist/worker.js"; + +const harness = createTestHarness({ manifest, capabilities: manifest.capabilities }); +await register(harness.ctx); + +// Simulate an event +await harness.emit("issue.created", { issueId: "iss-1", projectId: "proj-1" }); + +// Verify state was written +const state = await harness.state.get({ pluginId: manifest.id, scopeKind: "issue", scopeId: "iss-1", namespace: "sync", stateKey: "external-id" }); +expect(state).toBeDefined(); + +// Simulate a UI data request +const data = await harness.getData("sync-health", { companyId: "comp-1" }); +expect(data.syncedCount).toBeGreaterThan(0); +``` + +### 27.2 Local Plugin Development + +For developing a plugin against a running Paperclip instance: + +- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin` +- The host watches the plugin directory for changes and restarts the worker on rebuild. +- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload. +- The plugin settings page shows real-time logs from the worker for debugging. + +### 27.3 Plugin Starter Template + +The host should publish a starter template (`create-paperclip-plugin`) that scaffolds: + +- `package.json` with correct `paperclipPlugin` keys +- manifest with placeholder values +- worker entry with SDK type imports and example event handler +- UI entry with example `DashboardWidget` using bridge hooks +- test file using the test harness +- build configuration (esbuild or similar) for both worker and UI bundles +- `.gitignore` and `tsconfig.json` + +## 28. Example Mappings + +This spec directly supports the following plugin types: + +- `@paperclip/plugin-workspace-files` +- `@paperclip/plugin-terminal` +- `@paperclip/plugin-git` +- `@paperclip/plugin-linear` +- `@paperclip/plugin-github-issues` +- `@paperclip/plugin-grafana` +- `@paperclip/plugin-runtime-processes` +- `@paperclip/plugin-stripe` + +## 29. Compatibility And Versioning + +### 29.1 API Version Rules + +1. Host supports one or more explicit plugin API versions. +2. Plugin manifest declares exactly one `apiVersion`. +3. Host rejects unsupported versions at install time. +4. Plugin upgrades are explicit operator actions. +5. Capability expansion requires explicit operator approval. + +### 29.2 SDK Versioning + +The host publishes a single SDK package for plugin authors: + +- `@paperclipai/plugin-sdk` — the complete plugin SDK + +The package uses subpath exports to separate worker and UI concerns: + +- `@paperclipai/plugin-sdk` — worker-side SDK (context, events, state, tools, logger, `definePlugin`, `z`) +- `@paperclipai/plugin-sdk/ui` — frontend SDK (bridge hooks, shared components, design tokens) + +A single package simplifies dependency management for plugin authors — one dependency, one version, one changelog. The subpath exports keep bundle separation clean: worker code imports from the root, UI code imports from `/ui`. Build tools tree-shake accordingly so the worker bundle does not include React components and the UI bundle does not include worker-only code. + +Versioning rules: + +1. **Semver**: The SDK follows strict semantic versioning. Major version bumps indicate breaking changes to either the worker or UI surface; minor versions add new features backwards-compatibly; patch versions are bug fixes only. +2. **Tied to API version**: Each major SDK version corresponds to exactly one plugin `apiVersion`. When `@paperclipai/plugin-sdk@2.x` ships, it targets `apiVersion: 2`. Plugins built with SDK 1.x continue to declare `apiVersion: 1`. +3. **Host multi-version support**: The host must support at least the current and one previous `apiVersion` simultaneously. This means plugins built against the previous SDK major version continue to work without modification. The host maintains separate IPC protocol handlers for each supported API version. +4. **Minimum SDK version in manifest**: Plugins declare `sdkVersion` in the manifest as a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time and warns if the plugin's declared range is outside the host's supported SDK versions. +5. **Deprecation timeline**: When a new `apiVersion` ships, the previous version enters a deprecation period of at least 6 months. During this period: + - The host continues to load plugins targeting the deprecated version. + - The host logs a deprecation warning at plugin startup. + - The plugin settings page shows a banner indicating the plugin should be upgraded. + - After the deprecation period ends, the host may drop support for the old version in a future release. +6. **SDK changelog and migration guides**: Each major SDK release must include a migration guide documenting every breaking change, the new API surface, and a step-by-step upgrade path for plugin authors. +7. **UI surface stability**: Breaking changes to shared UI components (removing a component, changing required props) or design tokens require a major version bump just like worker API changes. The single-package model means both surfaces are versioned together, avoiding drift between worker and UI compatibility. + +### 29.3 Version Compatibility Matrix + +The host should publish a compatibility matrix: + +| Host Version | Supported API Versions | SDK Range | +|---|---|---| +| 1.0 | 1 | 1.x | +| 2.0 | 1, 2 | 1.x, 2.x | +| 3.0 | 2, 3 | 2.x, 3.x | + +This matrix is published in the host docs and queryable via `GET /api/plugins/compatibility`. + +### 29.4 Plugin Author Workflow + +When a new SDK version is released: + +1. Plugin author updates `@paperclipai/plugin-sdk` dependency. +2. Plugin author follows the migration guide to update code. +3. Plugin author updates `apiVersion` and `sdkVersion` in the manifest. +4. Plugin author publishes a new plugin version. +5. Operators upgrade the plugin on their instances. The old version continues to work until explicitly upgraded. + +## 30. Recommended Delivery Order + +## Phase 1 + +- plugin manifest +- install/list/remove/upgrade CLI +- global settings UI +- plugin process manager +- capability enforcement +- `plugins`, `plugin_config`, `plugin_state`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries` +- event bus +- jobs +- webhooks +- settings page +- plugin UI bundle loading, host bridge, and `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- bridge error propagation (`PluginBridgeError`) +- auto-generated settings form from `instanceConfigSchema` +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- `@paperclipai/plugin-test-harness` +- `create-paperclip-plugin` starter template +- uninstall with data retention grace period +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase is enough for: + +- Linear +- GitHub Issues +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- process/server tracking + +Workspace plugins (file browser, terminal, git, process tracking) do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2 + +- optional `plugin_entities` +- richer action systems +- trusted-module migration path if truly needed +- iframe-based isolation for untrusted plugin UI bundles +- plugin ecosystem/distribution work + +## 31. Final Design Decision + +Paperclip should not implement a generic in-process hook bag modeled directly after local coding tools. + +Paperclip should implement: + +- trusted platform modules for low-level host integration +- globally installed out-of-process plugins for additive instance-wide capabilities +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- auto-generated settings UI from config schema, with custom settings pages as an option +- plugin-to-plugin events for cross-plugin coordination +- server-side event filtering for efficient event routing +- plugins own their local tooling logic (filesystem, git, terminal, processes) directly +- generic extension tables for most plugin state +- graceful shutdown, uninstall data lifecycle, and plugin observability +- hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart +- SDK versioning with multi-version host support and a clear deprecation policy +- test harness and starter template for low authoring friction +- strict preservation of core governance and audit rules + +That is the complete target design for the Paperclip plugin system. diff --git a/doc/plugins/ideas-from-opencode.md b/doc/plugins/ideas-from-opencode.md new file mode 100644 index 00000000..fcef3c62 --- /dev/null +++ b/doc/plugins/ideas-from-opencode.md @@ -0,0 +1,1738 @@ +# Plugin Ideas From OpenCode + +Status: design report, not a V1 commitment + +Paperclip V1 explicitly excludes a plugin framework in [doc/SPEC-implementation.md](../SPEC-implementation.md), but the long-horizon spec says the architecture should leave room for extensions. This report studies the `opencode` plugin system and translates the useful patterns into a Paperclip-shaped design. + +Assumption for this document: Paperclip is a single-tenant operator-controlled instance. Plugin installation should therefore be global across the instance. "Companies" are still first-class Paperclip objects, but they are organizational records, not tenant-isolation boundaries for plugin trust or installation. + +## Executive Summary + +`opencode` has a real plugin system already. It is intentionally low-friction: + +- plugins are plain JS/TS modules +- they load from local directories and npm packages +- they can hook many runtime events +- they can add custom tools +- they can extend provider auth flows +- they run in-process and can mutate runtime behavior directly + +That model works well for a local coding tool. It should not be copied literally into Paperclip. + +The main conclusion is: + +- Paperclip should copy `opencode`'s typed SDK, deterministic loading, low authoring friction, and clear extension surfaces. +- Paperclip should not copy `opencode`'s trust model, project-local plugin loading, "override by name collision" behavior, or arbitrary in-process mutation hooks for core business logic. +- Paperclip should use multiple extension classes instead of one generic plugin bag: + - trusted in-process modules for low-level platform concerns like agent adapters, storage providers, secret providers, and possibly run-log backends + - out-of-process plugins for most third-party integrations like Linear, GitHub Issues, Grafana, Stripe, and schedulers + - plugin-contributed agent tools (namespaced, not override-by-collision) + - plugin-shipped React UI loaded into host extension slots via a typed bridge + - a typed event bus with server-side filtering and plugin-to-plugin events, plus scheduled jobs for automation + +If Paperclip does this well, the examples you listed become straightforward: + +- file browser / terminal / git workflow / child process tracking become workspace plugins that resolve paths from the host and handle OS operations directly +- Linear / GitHub / Grafana / Stripe become connector plugins +- future knowledge base and accounting features can also fit the same model + +## Sources Examined + +I cloned `anomalyco/opencode` and reviewed commit: + +- `a965a062595403a8e0083e85770315d5dc9628ab` + +Primary files reviewed: + +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/tool.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/plugin/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/config/config.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/tool/registry.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/provider/auth.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/plugins.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/custom-tools.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/ecosystem.mdx` + +Relevant Paperclip files reviewed for current extension seams: + +- [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) +- [doc/SPEC.md](../SPEC.md) +- [doc/SPEC-implementation.md](../SPEC-implementation.md) + +## What OpenCode Actually Implements + +## 1. Plugin authoring API + +`opencode` exposes a small package, `@opencode-ai/plugin`, with a typed `Plugin` function and a typed `tool()` helper. + +Core shape: + +- a plugin is an async function that receives a context object +- the plugin returns a `Hooks` object +- hooks are optional +- plugins can also contribute tools and auth providers + +The plugin init context includes: + +- an SDK client +- current project info +- current directory +- current git worktree +- server URL +- Bun shell access + +That is important: `opencode` gives plugins rich runtime power immediately, not a narrow capability API. + +## 2. Hook model + +The hook set is broad. It includes: + +- event subscription +- config-time hook +- message hooks +- model parameter/header hooks +- permission decision hooks +- shell env injection +- tool execution before/after hooks +- tool definition mutation +- compaction prompt customization +- text completion transforms + +The implementation pattern is very simple: + +- core code constructs an `output` object +- each matching plugin hook runs sequentially +- hooks mutate the `output` +- final mutated output is used by core + +This is elegant and easy to extend. + +It is also extremely powerful. A plugin can change auth headers, model params, permission answers, tool inputs, tool descriptions, and shell environment. + +## 3. Plugin discovery and load order + +`opencode` supports two plugin sources: + +- local files +- npm packages + +Local directories: + +- `~/.config/opencode/plugins/` +- `.opencode/plugins/` + +Npm plugins: + +- listed in config under `plugin: []` + +Load order is deterministic and documented: + +1. global config +2. project config +3. global plugin directory +4. project plugin directory + +Important details: + +- config arrays are concatenated rather than replaced +- duplicate plugin names are deduplicated with higher-precedence entries winning +- internal first-party plugins and default plugins are also loaded through the plugin pipeline + +This gives `opencode` a real precedence model rather than "whatever loaded last by accident." + +## 4. Dependency handling + +For local config/plugin directories, `opencode` will: + +- ensure a `package.json` exists +- inject `@opencode-ai/plugin` +- run `bun install` + +That lets local plugins and local custom tools import dependencies. + +This is excellent for local developer ergonomics. + +It is not a safe default for an operator-controlled control plane server. + +## 5. Error handling + +Plugin load failures do not hard-crash the runtime by default. + +Instead, `opencode`: + +- logs the error +- publishes a session error event +- continues loading other plugins + +That is a good operational pattern. One bad plugin should not brick the entire product unless the operator has explicitly configured it as required. + +## 6. Tools are a first-class extension point + +`opencode` has two ways to add tools: + +- export tools directly from a plugin via `hook.tool` +- define local files in `.opencode/tools/` or global tools directories + +The tool API is strong: + +- tools have descriptions +- tools have Zod schemas +- tool execution gets context like session ID, message ID, directory, and worktree +- tools are merged into the same registry as built-in tools +- tool definitions themselves can be mutated by a `tool.definition` hook + +The most aggressive part of the design: + +- custom tools can override built-in tools by name + +That is very powerful for a local coding assistant. +It is too dangerous for Paperclip core actions. + +However, the concept of plugins contributing agent-usable tools is very valuable for Paperclip — as long as plugin tools are namespaced (cannot shadow core tools) and capability-gated. + +## 7. Auth is also a plugin surface + +`opencode` allows plugins to register auth methods for providers. + +A plugin can contribute: + +- auth method metadata +- prompt flows +- OAuth flows +- API key flows +- request loaders that adapt provider behavior after auth succeeds + +This is a strong pattern worth copying. Integrations often need custom auth UX and token handling. + +## 8. Ecosystem evidence + +The ecosystem page is the best proof that the model is working in practice. +Community plugins already cover: + +- sandbox/workspace systems +- auth providers +- session headers / telemetry +- memory/context features +- scheduling +- notifications +- worktree helpers +- background agents +- monitoring + +That validates the main thesis: a simple typed plugin API can create real ecosystem velocity. + +## What OpenCode Gets Right + +## 1. Separate plugin SDK from host runtime + +This is one of the best parts of the design. + +- plugin authors code against a clean public package +- host internals can evolve behind the loader +- runtime code and plugin code have a clean contract boundary + +Paperclip should absolutely do this. + +## 2. Deterministic loading and precedence + +`opencode` is explicit about: + +- where plugins come from +- how config merges +- what order wins + +Paperclip should copy this discipline. + +## 3. Low-ceremony authoring + +A plugin author does not have to learn a giant framework. + +- export async function +- return hooks +- optionally export tools + +That simplicity matters. + +## 4. Typed tool definitions + +The `tool()` helper is excellent: + +- typed +- schema-based +- easy to document +- easy for runtime validation + +Paperclip should adopt this style for plugin actions, automations, and UI schemas. + +## 5. Built-in features and plugins use similar shapes + +`opencode` uses the same hook system for internal and external plugin-style behavior in several places. +That reduces special cases. + +Paperclip can benefit from that with adapters, secret backends, storage providers, and connector modules. + +## 6. Incremental extension, not giant abstraction upfront + +`opencode` did not design a giant marketplace platform first. +It added concrete extension points that real features needed. + +That is the correct mindset for Paperclip too. + +## What Paperclip Should Not Copy Directly + +## 1. In-process arbitrary plugin code as the default + +`opencode` is basically a local agent runtime, so unsandboxed plugin execution is acceptable for its audience. + +Paperclip is a control plane for an operator-managed instance with company objects. +The risk profile is different: + +- secrets matter +- approval gates matter +- budgets matter +- mutating actions require auditability + +Default third-party plugins should not run with unrestricted in-process access to server memory, DB handles, and secrets. + +## 2. Project-local plugin loading + +`opencode` has project-local plugin folders because the tool is centered around a codebase. + +Paperclip is not project-scoped. It is instance-scoped. +The comparable unit is: + +- instance-installed plugin package + +Paperclip should not auto-load arbitrary code from a workspace repo like `.paperclip/plugins` or project directories. + +## 3. Arbitrary mutation hooks on core business decisions + +Hooks like: + +- `permission.ask` +- `tool.execute.before` +- `chat.headers` +- `shell.env` + +make sense in `opencode`. + +For Paperclip, equivalent hooks into: + +- approval decisions +- issue checkout semantics +- activity log behavior +- budget enforcement + +would be a mistake. + +Core invariants should stay in core code, not become hook-rewritable. + +## 4. Override-by-name collision + +Allowing a plugin to replace a built-in tool by name is useful in a local agent product. + +Paperclip should not allow plugins to silently replace: + +- core routes +- core mutating actions +- auth behaviors +- permission evaluators +- budget logic +- audit logic + +Extension should be additive or explicitly delegated, never accidental shadowing. + +## 5. Auto-install and execute from user config + +`opencode`'s "install dependencies at startup" flow is ergonomic. +For Paperclip it would be risky because it combines: + +- package installation +- code loading +- execution + +inside the control-plane server startup path. + +Paperclip should require an explicit operator install step. + +## Why Paperclip Needs A Different Shape + +The products are solving different problems. + +| Topic | OpenCode | Paperclip | +|---|---|---| +| Primary unit | local project/worktree | single-tenant operator instance with company objects | +| Trust assumption | local power user on own machine | operator managing one trusted Paperclip instance | +| Failure blast radius | local session/runtime | entire company control plane | +| Extension style | mutate runtime behavior freely | preserve governance and auditability | +| UI model | local app can load local behavior | board UI must stay coherent and safe | +| Security model | host-trusted local plugins | needs capability boundaries and auditability | + +That means Paperclip should borrow the good ideas from `opencode` but use a stricter architecture. + +## Paperclip Already Has Useful Pre-Plugin Seams + +Paperclip has several extension-like seams already: + +- server adapter registry: [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- UI adapter registry: [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- storage provider registry: [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- secret provider registry: [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- pluggable run-log store seam: [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- activity log and live event emission: [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) + +This is good news. +Paperclip does not need to invent extensibility from scratch. +It needs to unify and harden existing seams. + +## Recommended Paperclip Plugin Model + +## 1. Use multiple extension classes + +Do not create one giant `hooks` object for everything. + +Use distinct plugin classes with different trust models. + +| Extension class | Examples | Runtime model | Trust level | Why | +|---|---|---|---|---| +| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | +| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | +| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | out-of-process, direct OS access | medium | resolves workspace paths from host, owns filesystem/git/PTY/process logic directly | +| UI contribution | dashboard widgets, settings forms, company panels | plugin-shipped React bundles in host extension slots via bridge | medium | plugins own their rendering; host controls slot placement and bridge access | +| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | + +This split is the most important design recommendation in this report. + +## 2. Keep low-level modules separate from third-party plugins + +Paperclip already has this pattern implicitly: + +- adapters are one thing +- storage providers are another +- secret providers are another + +Keep that separation. + +I would formalize it like this: + +- `module` means trusted code loaded by the host for low-level runtime services +- `plugin` means integration code that talks to Paperclip through a typed plugin protocol and capability model + +This avoids trying to force Stripe, a PTY terminal, and a new agent adapter into the same abstraction. + +## 3. Prefer event-driven extensions over core-logic mutation + +For third-party plugins, the primary API should be: + +- subscribe to typed domain events (with optional server-side filtering) +- emit plugin-namespaced events for cross-plugin communication +- read instance state, including company-bound business records when relevant +- register webhooks +- run scheduled jobs +- contribute tools that agents can use during runs +- write plugin-owned state +- add additive UI surfaces +- invoke explicit Paperclip actions through the API + +Do not make third-party plugins responsible for: + +- deciding whether an approval passes +- intercepting issue checkout semantics +- rewriting activity log behavior +- overriding budget hard-stops + +Those are core invariants. + +## 4. Plugins ship their own UI + +Plugins ship their own React UI as a bundled module inside `dist/ui/`. The host loads plugin components into designated **extension slots** (pages, tabs, widgets, sidebar entries) and provides a **bridge** for the plugin frontend to talk to its own worker backend and to access host context. + +**How it works:** + +1. The plugin's UI exports named components for each slot it fills (e.g. `DashboardWidget`, `IssueDetailTab`, `SettingsPage`). +2. The host mounts the plugin component into the correct slot, passing a bridge object with hooks like `usePluginData(key, params)` and `usePluginAction(key)`. +3. The plugin component fetches data from its own worker via the bridge and renders it however it wants. +4. The host enforces capability gates through the bridge — if the worker doesn't have a capability, the bridge rejects the call. + +**What the host controls:** where plugin components appear, the bridge API, capability enforcement, and shared UI primitives (`@paperclipai/plugin-sdk/ui`) with design tokens and common components. + +**What the plugin controls:** how to render its data, what data to fetch, what actions to expose, and whether to use the host's shared components or build entirely custom UI. + +First version extension slots: + +- dashboard widgets +- settings pages +- detail-page tabs (project, issue, agent, goal, run) +- sidebar entries +- company-context plugin pages + +The host SDK ships shared components (MetricCard, DataTable, StatusBadge, LogView, etc.) for visual consistency, but these are optional. + +Later, if untrusted third-party plugins become common, the host can move to iframe-based isolation without changing the plugin's source code (the bridge API stays the same). + +## 5. Make installation global and keep mappings/config separate + +`opencode` is mostly user-level local config. +Paperclip should treat plugin installation as a global instance-level action. + +Examples: + +- install `@paperclip/plugin-linear` once +- make it available everywhere immediately +- optionally store mappings over Paperclip objects if one company maps to a different Linear team than another + +## 6. Use project workspaces as the primary anchor for local tooling + +Paperclip already has a concrete workspace model for projects: + +- projects expose `workspaces` and `primaryWorkspace` +- the database already has `project_workspaces` +- project routes already support creating, updating, and deleting workspaces +- heartbeat resolution already prefers project workspaces before falling back to task-session or agent-home workspaces + +That means local/runtime plugins should generally anchor themselves to projects first, not invent a parallel workspace model. + +Practical guidance: + +- file browser should browse project workspaces first +- terminal sessions should be launchable from a project workspace +- git should treat the project workspace as the repo root anchor +- dev server and child-process tracking should attach to project workspaces +- issue and agent views can still deep-link into the relevant project workspace context + +In other words: + +- `project` is the business object +- `project_workspace` is the local runtime anchor +- plugins should build on that instead of creating an unrelated workspace model first + +## 7. Let plugins contribute agent tools + +`opencode` makes tools a first-class extension point. This is one of the highest-value surfaces for Paperclip too. + +A Linear plugin should be able to contribute a `search-linear-issues` tool that agents use during runs. A git plugin should contribute `create-branch` and `get-diff`. A file browser plugin should contribute `read-file` and `list-directory`. + +The key constraints: + +- plugin tools are namespaced by plugin ID (e.g. `linear:search-issues`) so they cannot shadow core tools +- plugin tools require the `agent.tools.register` capability +- tool execution goes through the same worker RPC boundary as everything else +- tool results appear in run logs + +This is a natural fit — the plugin already has the SDK context, the external API credentials, and the domain logic. Wrapping that in a tool definition is minimal additional work for the plugin author. + +## 8. Support plugin-to-plugin events + +Plugins should be able to emit custom events that other plugins can subscribe to. For example, the git plugin detects a push and emits `plugin.@paperclip/plugin-git.push-detected`. The GitHub Issues plugin subscribes to that event and updates PR links. + +This avoids plugins needing to coordinate through shared state or external channels. The host routes plugin events through the same event bus with the same delivery semantics as core events. + +Plugin events use a `plugin..*` namespace so they cannot collide with core events. + +## 9. Auto-generate settings UI from config schema + +Plugins that declare an `instanceConfigSchema` should get an auto-generated settings form for free. The host renders text inputs, dropdowns, toggles, arrays, and secret-ref pickers directly from the JSON Schema. + +For plugins that need richer settings UX, they can declare a `settingsPage` extension slot and ship a custom React component. Both approaches coexist. + +This matters because settings forms are boilerplate that every plugin needs. Auto-generating them from the schema that already exists removes a significant chunk of authoring friction. + +## 10. Design for graceful shutdown and upgrade + +The spec should be explicit about what happens when a plugin worker stops — during upgrades, uninstalls, or instance restarts. + +The recommended policy: + +- send `shutdown()` with a configurable deadline (default 10 seconds) +- SIGTERM after deadline, SIGKILL after 5 more seconds +- in-flight jobs marked `cancelled` +- in-flight bridge calls return structured errors to the UI + +For upgrades specifically: the old worker drains, the new worker starts. If the new version adds capabilities, it enters `upgrade_pending` until the operator approves. + +## 11. Define uninstall data lifecycle + +When a plugin is uninstalled, its data (`plugin_state`, `plugin_entities`, `plugin_jobs`, etc.) should be retained for a grace period (default 30 days), not immediately deleted. The operator can reinstall within the grace period and recover state, or force-purge via CLI. + +This matters because accidental uninstalls should not cause irreversible data loss. + +## 12. Invest in plugin observability + +Plugin logs via `ctx.logger` should be stored and queryable from the plugin settings page. The host should also capture raw `stdout`/`stderr` from the worker process as fallback. + +The plugin health dashboard should show: worker status, uptime, recent logs, job success/failure rates, webhook delivery rates, and resource usage. The host should emit internal events (`plugin.health.degraded`, `plugin.worker.crashed`) that other plugins or dashboards can consume. + +This is critical for operators. Without observability, debugging plugin issues requires SSH access and manual log tailing. + +## 13. Ship a test harness and starter template + +A `@paperclipai/plugin-test-harness` package should provide a mock host with in-memory stores, synthetic event emission, and `getData`/`performAction`/`executeTool` simulation. Plugin authors should be able to write unit tests without a running Paperclip instance. + +A `create-paperclip-plugin` CLI should scaffold a working plugin with manifest, worker, UI bundle, test file, and build config. + +Low authoring friction was called out as one of `opencode`'s best qualities. The test harness and starter template are how Paperclip achieves the same. + +## 14. Support hot plugin lifecycle + +Plugin install, uninstall, upgrade, and config changes should take effect without restarting the Paperclip server. This is critical for developer workflow and operator experience. + +The out-of-process worker architecture makes this natural: + +- **Hot install**: spawn a new worker, register its event subscriptions, job schedules, webhook endpoints, and agent tools in live routing tables, load its UI bundle into the extension slot registry. +- **Hot uninstall**: graceful shutdown of the worker, remove all registrations from routing tables, unmount UI components, start data retention grace period. +- **Hot upgrade**: shut down old worker, start new worker, atomically swap routing table entries, invalidate UI bundle cache so the frontend loads the updated bundle. +- **Hot config change**: write new config to `plugin_config`, notify the running worker via IPC (`configChanged`). The worker applies the change without restarting. If it doesn't handle `configChanged`, the host restarts just that worker. + +Frontend cache invalidation uses versioned or content-hashed bundle URLs and a `plugin.ui.updated` event that triggers re-import without a full page reload. + +Each worker process is independent — starting, stopping, or replacing one worker never affects any other plugin or the host itself. + +## 15. Define SDK versioning and compatibility + +`opencode` does not have a formal SDK versioning story because plugins run in-process and are effectively pinned to the current runtime. Paperclip's out-of-process model means plugins may be built against one SDK version and run on a host that has moved forward. This needs explicit rules. + +Recommended approach: + +- **Single SDK package**: `@paperclipai/plugin-sdk` with subpath exports — root for worker code, `/ui` for frontend code. One dependency, one version, one changelog. +- **SDK major version = API version**: `@paperclipai/plugin-sdk@2.x` targets `apiVersion: 2`. Plugins built with SDK 1.x declare `apiVersion: 1` and continue to work. +- **Host multi-version support**: The host supports at least the current and one previous `apiVersion` simultaneously with separate IPC protocol handlers per version. +- **`sdkVersion` in manifest**: Plugins declare a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time. +- **Deprecation timeline**: Previous API versions get at least 6 months of continued support after a new version ships. The host logs deprecation warnings and shows a banner on the plugin settings page. +- **Migration guides**: Each major SDK release ships with a step-by-step migration guide covering every breaking change. +- **UI surface versioned with worker**: Both worker and UI surfaces are in the same package, so they version together. Breaking changes to shared UI components require a major version bump just like worker API changes. +- **Published compatibility matrix**: The host publishes a matrix of supported API versions and SDK ranges, queryable via API. + +## A Concrete SDK Shape For Paperclip + +An intentionally narrow first pass could look like this: + +```ts +import { definePlugin, z } from "@paperclipai/plugin-sdk"; + +export default definePlugin({ + id: "@paperclip/plugin-linear", + version: "0.1.0", + categories: ["connector", "ui"], + capabilities: [ + "events.subscribe", + "jobs.schedule", + "http.outbound", + "instance.settings.register", + "ui.dashboardWidget.register", + "secrets.read-ref", + ], + instanceConfigSchema: z.object({ + linearBaseUrl: z.string().url().optional(), + companyMappings: z.array( + z.object({ + companyId: z.string(), + teamId: z.string(), + apiTokenSecretRef: z.string(), + }), + ).default([]), + }), + async register(ctx) { + ctx.jobs.register("linear-pull", { cron: "*/5 * * * *" }, async (job) => { + // sync Linear issues into plugin-owned state or explicit Paperclip entities + }); + + // subscribe with optional server-side filter + ctx.events.on("issue.created", { projectId: "proj-1" }, async (event) => { + // only receives issue.created events for project proj-1 + }); + + // subscribe to events from another plugin + ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push + }); + + // contribute a tool that agents can use during runs + ctx.tools.register("search-linear-issues", { + displayName: "Search Linear Issues", + description: "Search for Linear issues by query", + parametersSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, async (params, runCtx) => { + // search Linear API and return results + return { content: JSON.stringify(results) }; + }); + + // getData is called by the plugin's own UI components via the host bridge + ctx.data.register("sync-health", async ({ companyId }) => { + // return typed JSON that the plugin's DashboardWidget component renders + return { syncedCount: 142, trend: "+12 today", mappings: [...] }; + }); + + ctx.actions.register("resync", async ({ companyId }) => { + // run sync logic + }); + }, +}); +``` + +The plugin's UI bundle (separate from the worker) might look like: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, ErrorBoundary } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading, error } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + if (error) return
Plugin error: {error.message} ({error.code})
; + + return ( + Widget failed to render}> + + + + ); +} +``` + +The important point is not the exact syntax. +The important point is the contract shape: + +- typed manifest +- explicit capabilities +- explicit global config with optional company mappings +- event subscriptions with optional server-side filtering +- plugin-to-plugin events via namespaced event types +- agent tool contributions +- jobs +- plugin-shipped UI that communicates with its worker through the host bridge +- structured error propagation from worker to UI + +## Recommended Core Extension Surfaces + +## 1. Platform module surfaces + +These should stay close to the current registry style. + +Candidates: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +These are trusted platform modules, not casual plugins. + +## 2. Connector plugin surfaces + +These are the best near-term plugin candidates. + +Capabilities: + +- subscribe to domain events +- define scheduled sync jobs +- expose plugin-specific API routes under `/api/plugins/:pluginId/...` +- use company secret refs +- write plugin state +- publish dashboard data +- log activity through core APIs + +Examples: + +- Linear issue sync +- GitHub issue sync +- Grafana dashboard cards +- Stripe MRR / subscription rollups + +## 3. Workspace-runtime surfaces + +Workspace plugins handle local tooling directly: + +- file browser +- terminal +- git workflow +- child process tracking +- local dev server tracking + +Plugins resolve workspace paths through host APIs (`ctx.projects` provides workspace metadata including `cwd`, `repoUrl`, etc.) and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +The host does not wrap or proxy these operations. This keeps the core lean — no need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own implementations. + +## Governance And Safety Requirements + +Any Paperclip plugin system has to preserve core control-plane invariants from the repo docs. + +That means: + +- plugin install is global to the instance +- "companies" remain business objects in the API and data model, not tenant boundaries +- approval gates remain core-owned +- budget hard-stops remain core-owned +- mutating actions are activity-logged +- secrets remain ref-based and redacted in logs + +I would require the following for every plugin: + +## 1. Capability declaration + +Every plugin declares a static capability set such as: + +- `companies.read` +- `issues.read` +- `issues.write` +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `http.outbound` +- `webhooks.receive` +- `assets.read` +- `assets.write` +- `secrets.read-ref` +- `agent.tools.register` +- `plugin.state.read` +- `plugin.state.write` + +The board/operator sees this before installation. + +## 2. Global installation + +A plugin is installed once and becomes available across the instance. +If it needs mappings over specific Paperclip objects, those are plugin data, not enable/disable boundaries. + +## 3. Activity logging + +Plugin-originated mutations should flow through the same activity log mechanism, with a dedicated `plugin` actor type: + +- `actor_type = plugin` +- `actor_id = ` (e.g. `@paperclip/plugin-linear`) + +## 4. Health and failure reporting + +Each plugin should expose: + +- enabled/disabled state +- last successful run +- last error +- recent webhook/job history + +One broken plugin must not break the rest of the company. + +## 5. Secret handling + +Plugins should receive secret refs, not raw secret values in config persistence. +Resolution should go through the existing secret provider abstraction. + +## 6. Resource limits + +Plugins should have: + +- timeout limits +- concurrency limits +- retry policies +- optional per-plugin budgets + +This matters especially for sync connectors and workspace plugins. + +## Data Model Additions To Consider + +I would avoid "arbitrary third-party plugin-defined SQL migrations" in the first version. +That is too much power too early. + +The right mental model is: + +- reuse core tables when the data is clearly part of Paperclip itself +- use generic extension tables for most plugin-owned state +- only allow plugin-specific tables later, and only for trusted platform modules or a tightly controlled migration workflow + +## Recommended Postgres Strategy For Extensions + +### 1. Core tables stay core + +If a concept is becoming part of Paperclip's actual product model, it should get a normal first-party table. + +Examples: + +- `project_workspaces` is already a core table because project workspaces are now part of Paperclip itself +- if a future "project git state" becomes a core feature rather than plugin-owned metadata, that should also be a first-party table + +### 2. Most plugins should start in generic extension tables + +For most plugins, the host should provide a few generic persistence tables and the plugin stores namespaced records there. + +This keeps the system manageable: + +- simpler migrations +- simpler backup/restore +- simpler portability story +- easier operator review +- fewer chances for plugin schema drift to break the instance + +### 3. Scope plugin data to Paperclip objects before adding custom schemas + +A lot of plugin data naturally hangs off existing Paperclip objects: + +- project workspace plugin state should often scope to `project` or `project_workspace` +- issue sync state should scope to `issue` +- metrics widgets may scope to `company`, `project`, or `goal` +- process tracking may scope to `project_workspace`, `agent`, or `run` + +That gives a good default keying model before introducing custom tables. + +### 4. Add trusted module migrations later, not arbitrary plugin migrations now + +If Paperclip eventually needs extension-owned tables, I would only allow that for: + +- trusted first-party packages +- trusted platform modules +- maybe explicitly installed admin-reviewed plugins with pinned versions + +I would not let random third-party plugins run free-form schema migrations on startup. + +Instead, add a controlled mechanism later if it becomes necessary. + +## Suggested baseline extension tables + +## 1. `plugins` + +Instance-level installation record. + +Suggested fields: + +- `id` +- `package_name` +- `version` +- `categories` +- `manifest_json` +- `installed_at` +- `status` + +## 2. `plugin_config` + +Instance-level plugin config. + +Suggested fields: + +- `id` +- `plugin_id` +- `config_json` +- `created_at` +- `updated_at` +- `last_error` + +## 3. `plugin_state` + +Generic key/value state for plugins. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` (`instance | company | project | project_workspace | agent | issue | goal | run`) +- `scope_id` nullable +- `namespace` +- `state_key` +- `value_json` +- `updated_at` + +This is enough for many connectors before allowing custom tables. + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +## 4. `plugin_jobs` + +Scheduled job and run tracking. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `job_key` +- `status` +- `last_started_at` +- `last_finished_at` +- `last_error` + +## 5. `plugin_webhook_deliveries` + +If plugins expose webhooks, delivery history is worth storing. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `endpoint_key` +- `status` +- `received_at` +- `response_code` +- `error` + +## 6. Maybe later: `plugin_entities` + +If generic plugin state becomes too limiting, add a structured, queryable entity table for connector records before allowing arbitrary plugin migrations. + +Suggested fields: + +- `id` +- `plugin_id` +- `entity_type` +- `scope_kind` +- `scope_id` +- `external_id` +- `title` +- `status` +- `data_json` +- `updated_at` + +This is a useful middle ground: + +- much more queryable than opaque key/value state +- still avoids letting every plugin create its own relational schema immediately + +## How The Requested Examples Map To This Model + +| Use case | Best fit | Host primitives needed | Notes | +|---|---|---|---| +| File browser | workspace plugin | project workspace metadata | plugin owns filesystem ops directly | +| Terminal | workspace plugin | project workspace metadata | plugin spawns PTY sessions directly | +| Git workflow | workspace plugin | project workspace metadata | plugin shells out to git directly | +| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | +| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | +| Grafana metrics | connector plugin + dashboard widget | outbound HTTP | probably read-only first | +| Child process/server tracking | workspace plugin | project workspace metadata | plugin manages processes directly | +| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate | + +# Plugin Examples + +## Workspace File Browser + +Package idea: `@paperclip/plugin-workspace-files` + +This plugin lets the board inspect project workspaces, agent workspaces, generated artifacts, and issue-related files without dropping to the shell. It is useful for: + +- browsing files inside project workspaces +- debugging what an agent changed +- reviewing generated outputs before approval +- attaching files from a workspace to issues +- understanding repo layout for a company +- inspecting agent home workspaces in local-trusted mode + +### UX + +- Settings page: `/settings/plugins/workspace-files` +- Main page: `/:companyPrefix/plugins/workspace-files` +- Project tab: `/:companyPrefix/projects/:projectId?tab=files` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=files` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=workspace` + +Main screens and interactions: + +- Plugin settings: + - choose whether the plugin defaults to `project.primaryWorkspace` + - choose which project workspaces are visible + - choose whether file writes are allowed or read-only + - choose whether hidden files are visible +- Main explorer page: + - project picker at the top + - workspace picker scoped to the selected project's `workspaces` + - tree view on the left + - file preview pane on the right + - search box for filename/path search + - actions: copy path, download file, attach to issue, open diff +- Project tab: + - opens directly into the project's primary workspace + - lets the board switch among all project workspaces + - shows workspace metadata like `cwd`, `repoUrl`, and `repoRef` +- Issue tab: + - resolves the issue's project and opens that project's workspace context + - shows files linked to the issue + - lets the board pull files from the project workspace into issue attachments + - shows the path and last modified info for each linked file +- Agent tab: + - shows the agent's current resolved workspace + - if the run is attached to a project, links back to the project workspace view + - lets the board inspect files the agent is currently touching + +Core workflows: + +- Board opens a project and browses its primary workspace files. +- Board switches from one project workspace to another when a project has multiple checkouts or repo references. +- Board opens an issue, attaches a generated artifact from the file browser, and leaves a review comment. +- Board opens an agent detail page to inspect the exact files behind a failing run. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `projects.read` +- `project.workspaces.read` +- optional `assets.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all filesystem operations (read, write, stat, search, list directory) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` +- `events.subscribe(issue.attachment.created)` + +## Workspace Terminal + +Package idea: `@paperclip/plugin-terminal` + +This plugin gives the board a controlled terminal UI for project workspaces and agent workspaces. It is useful for: + +- debugging stuck runs +- verifying environment state +- running targeted manual commands +- watching long-running commands +- pairing a human operator with an agent workflow + +### UX + +- Settings page: `/settings/plugins/terminal` +- Main page: `/:companyPrefix/plugins/terminal` +- Project tab: `/:companyPrefix/projects/:projectId?tab=terminal` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=terminal` +- Optional run tab: `/:companyPrefix/agents/:agentId/runs/:runId?tab=terminal` + +Main screens and interactions: + +- Plugin settings: + - allowed shells and shell policy + - whether commands are read-only, free-form, or allow-listed + - whether terminals require an explicit operator confirmation before launch + - whether new terminal sessions default to the project's primary workspace +- Terminal home page: + - list of active terminal sessions + - button to open a new session + - project picker, then workspace picker from that project's workspaces + - optional agent association + - terminal panel with input, resize, and reconnect support + - controls: interrupt, kill, clear, save transcript +- Project terminal tab: + - opens a session already scoped to the project's primary workspace + - lets the board switch among the project's configured workspaces + - shows recent commands and related process/server state for that project +- Agent terminal tab: + - opens a session already scoped to the agent's workspace + - shows recent related runs and commands +- Run terminal tab: + - lets the board inspect the environment around a specific failed run + +Core workflows: + +- Board opens a terminal against an agent workspace to reproduce a failing command. +- Board opens a project page and launches a terminal directly in that project's primary workspace. +- Board watches a long-running dev server or test command from the terminal page. +- Board kills or interrupts a runaway process from the same UI. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `agent`, and `run` +- `projects.read` +- `project.workspaces.read` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles PTY session management (open, input, resize, terminate, subscribe) directly using Node PTY libraries. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.failed)` +- `events.subscribe(agent.run.cancelled)` + +## Git Workflow + +Package idea: `@paperclip/plugin-git` + +This plugin adds repo-aware workflow tooling around issues and workspaces. It is useful for: + +- branch creation tied to issues +- quick diff review +- commit and worktree visibility +- PR preparation +- treating the project's primary workspace as the canonical repo anchor +- seeing whether an agent's workspace is clean or dirty + +### UX + +- Settings page: `/settings/plugins/git` +- Main page: `/:companyPrefix/plugins/git` +- Project tab: `/:companyPrefix/projects/:projectId?tab=git` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=git` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=git` + +Main screens and interactions: + +- Plugin settings: + - branch naming template + - optional remote provider token secret ref + - whether write actions are enabled or read-only + - whether the plugin always uses `project.primaryWorkspace` unless a different project workspace is chosen +- Git overview page: + - project picker and workspace picker + - current branch + - ahead/behind status + - dirty files summary + - recent commits + - active worktrees + - actions: refresh, create branch, create worktree, stage all, commit, open diff +- Project tab: + - opens in the project's primary workspace + - shows workspace metadata and repo binding (`cwd`, `repoUrl`, `repoRef`) + - shows branch, diff, and commit history for that project workspace +- Issue tab: + - resolves the issue's project and uses that project's workspace context + - "create branch from issue" action + - diff view scoped to the project's selected workspace + - link branch/worktree metadata to the issue +- Agent tab: + - shows the agent's branch, worktree, and dirty state + - shows recent commits produced by that agent + - if the agent is working inside a project workspace, links back to the project git tab + +Core workflows: + +- Board creates a branch from an issue and ties it to the project's primary workspace. +- Board opens a project page and reviews the diff for that project's workspace without leaving Paperclip. +- Board reviews the diff after a run without leaving Paperclip. +- Board opens a worktree list to understand parallel branches across agents. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `ui.action.register` +- `projects.read` +- `project.workspaces.read` +- optional `agent.tools.register` (e.g. `create-branch`, `get-diff`, `get-status`) +- optional `events.emit` (e.g. `plugin.@paperclip/plugin-git.push-detected`) +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all git operations (status, diff, log, branch create, commit, worktree create, push) directly using git CLI or a git library. + +Optional event subscriptions: + +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(agent.run.finished)` + +The git plugin can emit `plugin.@paperclip/plugin-git.push-detected` events that other plugins (e.g. GitHub Issues) subscribe to for cross-plugin coordination. + +Note: GitHub/GitLab PR creation should likely live in a separate connector plugin rather than overloading the local git plugin. + +## Linear Issue Tracking + +Package idea: `@paperclip/plugin-linear` + +This plugin syncs Paperclip work with Linear. It is useful for: + +- importing backlog from Linear +- linking Paperclip issues to Linear issues +- syncing status, comments, and assignees +- mapping company goals/projects to external product planning +- giving board operators a single place to see sync health + +### UX + +- Settings page: `/settings/plugins/linear` +- Main page: `/:companyPrefix/plugins/linear` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=linear` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=linear` + +Main screens and interactions: + +- Plugin settings: + - Linear API token secret ref + - workspace/team/project mappings + - status mapping between Paperclip and Linear + - sync direction: import only, export only, bidirectional + - comment sync toggle +- Linear overview page: + - sync health card + - recent sync jobs + - mapped projects and teams + - unresolved conflicts queue + - import actions for teams, projects, and issues +- Issue tab: + - linked Linear issue key and URL + - sync status and last synced time + - actions: link existing, create in Linear, resync now, unlink + - timeline of synced comments/status changes +- Dashboard widget: + - open sync errors + - imported vs linked issues count + - recent webhook/job failures + +Core workflows: + +- Board enables the plugin, maps a Linear team, and imports a backlog into Paperclip. +- Paperclip issue status changes push to Linear and Linear comments arrive back through webhooks. +- Board resolves mapping conflicts from the plugin page instead of silently drifting state. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(project.updated)` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- optional `agent.tools.register` (e.g. `search-linear-issues`, `get-linear-issue`) +- `activity.log.write` + +Important constraint: + +- webhook processing should be idempotent and conflict-aware +- external IDs and sync cursors belong in plugin-owned state, not inline on core issue rows in the first version + +## GitHub Issue Tracking + +Package idea: `@paperclip/plugin-github-issues` + +This plugin syncs Paperclip issues with GitHub Issues and optionally links PRs. It is useful for: + +- importing repo backlogs +- mirroring issue status and comments +- linking PRs to Paperclip issues +- tracking cross-repo work from inside one company view +- bridging engineering workflow with Paperclip task governance + +### UX + +- Settings page: `/settings/plugins/github-issues` +- Main page: `/:companyPrefix/plugins/github-issues` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=github` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=github` + +Main screens and interactions: + +- Plugin settings: + - GitHub App or PAT secret ref + - org/repo mappings + - label/status mapping + - whether PR linking is enabled + - whether new Paperclip issues should create GitHub issues automatically +- GitHub overview page: + - repo mapping list + - sync health and recent webhook events + - import backlog action + - queue of unlinked GitHub issues +- Issue tab: + - linked GitHub issue and optional linked PRs + - actions: create GitHub issue, link existing issue, unlink, resync + - comment/status sync timeline +- Dashboard widget: + - open PRs linked to active Paperclip issues + - webhook failures + - sync lag metrics + +Core workflows: + +- Board imports GitHub Issues for a repo into Paperclip. +- GitHub webhooks update status/comment state in Paperclip. +- A PR is linked back to the Paperclip issue so the board can follow delivery status. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(plugin.@paperclip/plugin-git.push-detected)` (cross-plugin coordination) +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- `activity.log.write` + +Important constraint: + +- keep "local git state" and "remote GitHub issue state" in separate plugins even if they work together — cross-plugin events handle coordination + +## Grafana Metrics + +Package idea: `@paperclip/plugin-grafana` + +This plugin surfaces external metrics and dashboards inside Paperclip. It is useful for: + +- company KPI visibility +- infrastructure/incident monitoring +- showing deploy, traffic, latency, or revenue charts next to work +- creating Paperclip issues from anomalous metrics + +### UX + +- Settings page: `/settings/plugins/grafana` +- Main page: `/:companyPrefix/plugins/grafana` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional goal tab: `/:companyPrefix/goals/:goalId?tab=metrics` + +Main screens and interactions: + +- Plugin settings: + - Grafana base URL + - service account token secret ref + - dashboard and panel mappings + - refresh interval + - optional alert threshold rules +- Dashboard widgets: + - one or more metric cards on the main dashboard + - quick trend view and last refresh time + - link out to Grafana and link in to the full Paperclip plugin page +- Full metrics page: + - selected dashboard panels embedded or proxied + - metric selector + - time range selector + - "create issue from anomaly" action +- Goal tab: + - metric cards relevant to a specific goal or project + +Core workflows: + +- Board sees service degradation or business KPI movement directly on the Paperclip dashboard. +- Board clicks into the full metrics page to inspect the relevant Grafana panels. +- Board creates a Paperclip issue from a threshold breach with a metric snapshot attached. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `ui.detailTab.register` for `goal` or `project` +- `jobs.schedule` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `assets.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(goal.created)` +- `events.subscribe(project.updated)` + +Important constraint: + +- start read-only first +- do not make Grafana alerting logic part of Paperclip core; keep it as additive signal and issue creation + +## Child Process / Server Tracking + +Package idea: `@paperclip/plugin-runtime-processes` + +This plugin tracks long-lived local processes and dev servers started in project workspaces. It is useful for: + +- seeing which agent started which local service +- tracking ports, health, and uptime +- restarting failed dev servers +- exposing process state alongside issue and run state +- making local development workflows visible to the board + +### UX + +- Settings page: `/settings/plugins/runtime-processes` +- Main page: `/:companyPrefix/plugins/runtime-processes` +- Dashboard widget: `/:companyPrefix/dashboard` +- Process detail page: `/:companyPrefix/plugins/runtime-processes/:processId` +- Project tab: `/:companyPrefix/projects/:projectId?tab=processes` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=processes` + +Main screens and interactions: + +- Plugin settings: + - whether manual process registration is allowed + - health check behavior + - whether operators can stop/restart processes + - log retention preferences +- Process list page: + - status table with name, command, cwd, owner agent, port, uptime, and health + - filters for running/exited/crashed processes + - actions: inspect, stop, restart, tail logs +- Project tab: + - filters the process list to the project's workspaces + - shows which workspace each process belongs to + - groups processes by project workspace +- Process detail page: + - process metadata + - live log tail + - health check history + - links to associated issue or run +- Agent tab: + - shows processes started by or assigned to that agent + +Core workflows: + +- An agent starts a dev server; the plugin detects and tracks it. +- Board opens a project and immediately sees the processes attached to that project's workspace. +- Board sees a crashed process on the dashboard and restarts it from the plugin page. +- Board attaches process logs to an issue when debugging a failure. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `project` and `agent` +- `projects.read` +- `project.workspaces.read` +- `plugin.state.read` +- `plugin.state.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles process management (register, list, terminate, restart, read logs, health probes) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` + +## Stripe Revenue Tracking + +Package idea: `@paperclip/plugin-stripe` + +This plugin pulls Stripe revenue and subscription data into Paperclip. It is useful for: + +- showing MRR and churn next to company goals +- tracking trials, conversions, and failed payments +- letting the board connect revenue movement to ongoing work +- enabling future financial dashboards beyond token costs + +### UX + +- Settings page: `/settings/plugins/stripe` +- Main page: `/:companyPrefix/plugins/stripe` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional company/goal metric tabs if those surfaces exist later + +Main screens and interactions: + +- Plugin settings: + - Stripe secret key secret ref + - account selection if needed + - metric definitions such as MRR treatment and trial handling + - sync interval + - webhook signing secret ref +- Dashboard widgets: + - MRR card + - active subscriptions + - trial-to-paid conversion + - failed payment alerts +- Stripe overview page: + - time series charts + - recent customer/subscription events + - webhook health + - sync history + - action: create issue from billing anomaly + +Core workflows: + +- Board enables the plugin and connects a Stripe account. +- Webhooks and scheduled reconciliation keep plugin state current. +- Revenue widgets appear on the main dashboard and can be linked to company goals. +- Failed payment spikes or churn events can generate Paperclip issues for follow-up. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- `metrics.write` +- optional `issues.create` +- `activity.log.write` + +Important constraint: + +- Stripe data should stay additive to Paperclip core +- it should not leak into core budgeting logic, which is specifically about model/token spend in V1 + +## Specific Patterns From OpenCode Worth Adopting + +## Adopt + +- separate SDK package from runtime loader +- deterministic load order and precedence +- very small authoring API +- typed schemas for plugin inputs/config/tools +- tools as a first-class plugin extension point (namespaced, not override-by-collision) +- internal extensions using the same registration shapes as external ones when reasonable +- plugin load errors isolated from host startup when possible +- explicit community-facing plugin docs and example templates +- test harness and starter template for low authoring friction +- hot plugin lifecycle without server restart (enabled by out-of-process workers) +- formal SDK versioning with multi-version host support + +## Adapt, not copy + +- local path loading +- dependency auto-install +- hook mutation model +- built-in override behavior +- broad runtime context objects + +## Avoid + +- project-local arbitrary code loading +- implicit trust of npm packages at startup +- plugins overriding core invariants +- unsandboxed in-process execution as the default extension model + +## Suggested Rollout Plan + +## Phase 0: Harden the seams that already exist + +- formalize adapter/storage/secret/run-log registries as "platform modules" +- remove ad-hoc fallback behavior where possible +- document stable registration contracts + +## Phase 1: Add connector plugins first + +This is the highest-value, lowest-risk plugin category. + +Build: + +- plugin manifest +- global install/update lifecycle +- global plugin config and optional company-mapping storage +- secret ref access +- typed domain event subscription +- scheduled jobs +- webhook endpoints +- activity logging helpers +- plugin UI bundle loading, host bridge, `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- auto-generated settings form from `instanceConfigSchema` +- bridge error propagation (`PluginBridgeError`) +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering (server-side, per-subscription) +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- uninstall with data retention grace period +- `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase would immediately cover: + +- Linear +- GitHub +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- child process/server tracking + +Workspace plugins do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2: Consider richer UI and plugin packaging + +Only after Phase 1 is stable: + +- iframe-based isolation for untrusted third-party plugin UI bundles +- signed/verified plugin packages +- plugin marketplace +- optional custom plugin storage backends or migrations + +## Recommended Architecture Decision + +If I had to collapse this report into one architectural decision, it would be: + +Paperclip should not implement "an OpenCode-style generic in-process hook system." +Paperclip should implement "a plugin platform with multiple trust tiers": + +- trusted platform modules for low-level runtime integration +- typed out-of-process plugins for instance-wide integrations and automation +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- plugin-to-plugin events for cross-plugin coordination +- auto-generated settings UI from config schema +- core-owned invariants that plugins can observe and act around, but not replace +- plugin observability, graceful lifecycle management, and a test harness for low authoring friction +- hot plugin lifecycle — no server restart for install, uninstall, upgrade, or config changes +- SDK versioning with multi-version host support and clear deprecation policy + +That gets the upside of `opencode`'s extensibility without importing the wrong threat model. + +## Concrete Next Steps I Would Take In Paperclip + +1. Write a short extension architecture RFC that formalizes the distinction between `platform modules` and `plugins`. +2. Introduce a small plugin manifest type in `packages/shared` and a `plugins` install/config section in the instance config. +3. Build a typed domain event bus around existing activity/live-event patterns, with server-side event filtering and a `plugin.*` namespace for cross-plugin events. Keep core invariants non-hookable. +4. Implement plugin MVP: global install/config, secret refs, jobs, webhooks, plugin UI bundles, extension slots, auto-generated settings forms, bridge error propagation. +5. Add agent tool contributions — plugins register namespaced tools that agents can call during runs. +6. Add plugin observability: structured logging via `ctx.logger`, health dashboard, internal health events. +7. Add graceful shutdown policy and uninstall data lifecycle with retention grace period. +8. Ship `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template. +9. Implement hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart. +10. Define SDK versioning policy — semver, multi-version host support, deprecation timeline, migration guides, published compatibility matrix. +11. Build workspace plugins (file browser, terminal, git, process tracking) that resolve workspace paths from the host and handle OS-level operations directly. diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index 4c172c7b..f0d02275 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -249,7 +249,7 @@ Runs local `claude` CLI directly. "cwd": "/absolute/or/relative/path", "promptTemplate": "You are agent {{agent.id}} ...", "model": "optional-model-id", - "maxTurnsPerRun": 80, + "maxTurnsPerRun": 300, "dangerouslySkipPermissions": true, "env": {"KEY": "VALUE"}, "extraArgs": [], diff --git a/doc/spec/ui.md b/doc/spec/ui.md index c7779393..c2ffdb7c 100644 --- a/doc/spec/ui.md +++ b/doc/spec/ui.md @@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header. My Issues ``` -- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count. +- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count. - **My Issues** — issues created by or assigned to the board operator. ### 3.3 Work Section diff --git a/docker-compose.untrusted-review.yml b/docker-compose.untrusted-review.yml new file mode 100644 index 00000000..ff11148a --- /dev/null +++ b/docker-compose.untrusted-review.yml @@ -0,0 +1,33 @@ +services: + review: + build: + context: . + dockerfile: docker/untrusted-review/Dockerfile + init: true + tty: true + stdin_open: true + working_dir: /work + environment: + HOME: "/home/reviewer" + CODEX_HOME: "/home/reviewer/.codex" + CLAUDE_HOME: "/home/reviewer/.claude" + PAPERCLIP_HOME: "/home/reviewer/.paperclip-review" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + GITHUB_TOKEN: "${GITHUB_TOKEN:-}" + ports: + - "${REVIEW_PAPERCLIP_PORT:-3100}:3100" + - "${REVIEW_VITE_PORT:-5173}:5173" + volumes: + - review-home:/home/reviewer + - review-work:/work + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:mode=1777,size=1g + +volumes: + review-home: + review-work: diff --git a/docker/untrusted-review/Dockerfile b/docker/untrusted-review/Dockerfile new file mode 100644 index 00000000..c8b1f432 --- /dev/null +++ b/docker/untrusted-review/Dockerfile @@ -0,0 +1,44 @@ +FROM node:lts-trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + fd-find \ + gh \ + git \ + jq \ + less \ + openssh-client \ + procps \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd + +RUN corepack enable \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest + +RUN useradd --create-home --shell /bin/bash reviewer + +ENV HOME=/home/reviewer \ + CODEX_HOME=/home/reviewer/.codex \ + CLAUDE_HOME=/home/reviewer/.claude \ + PAPERCLIP_HOME=/home/reviewer/.paperclip-review \ + PNPM_HOME=/home/reviewer/.local/share/pnpm \ + PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +WORKDIR /work + +COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr + +RUN chmod +x /usr/local/bin/review-checkout-pr \ + && mkdir -p /work \ + && chown -R reviewer:reviewer /work + +USER reviewer + +EXPOSE 3100 5173 + +CMD ["bash", "-l"] diff --git a/docker/untrusted-review/bin/review-checkout-pr b/docker/untrusted-review/bin/review-checkout-pr new file mode 100644 index 00000000..abca98ad --- /dev/null +++ b/docker/untrusted-review/bin/review-checkout-pr @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: review-checkout-pr [checkout-dir] + +Examples: + review-checkout-pr paperclipai/paperclip 432 + review-checkout-pr https://github.com/paperclipai/paperclip.git 432 +EOF +} + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage >&2 + exit 1 +fi + +normalize_repo_slug() { + local raw="$1" + raw="${raw#git@github.com:}" + raw="${raw#ssh://git@github.com/}" + raw="${raw#https://github.com/}" + raw="${raw#http://github.com/}" + raw="${raw%.git}" + printf '%s\n' "${raw#/}" +} + +repo_slug="$(normalize_repo_slug "$1")" +pr_number="$2" + +if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then + echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2 + exit 1 +fi + +if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then + echo "PR number must be numeric, got: $pr_number" >&2 + exit 1 +fi + +repo_key="${repo_slug//\//-}" +mirror_dir="/work/repos/${repo_key}" +checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}" +pr_ref="refs/remotes/origin/pr/${pr_number}" + +mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")" + +if [[ ! -d "$mirror_dir/.git" ]]; then + if command -v gh >/dev/null 2>&1; then + gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none + else + git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir" + fi +fi + +git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}" + +if [[ -e "$checkout_dir" ]]; then + printf '%s\n' "$checkout_dir" + exit 0 +fi + +git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null +printf '%s\n' "$checkout_dir" diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 254689a2..c6029e0c 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | | `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | ## Prompt Templates @@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory. +For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: + +```sh +pnpm paperclipai agent local-cli claudecoder --company-id +``` + +This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test Use the "Test Environment" button in the UI to validate the adapter config. It checks: diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index d87172f8..ad187f75 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -30,6 +30,16 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity. + +For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +``` + +This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test The environment test checks: diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index e33b5411..fae0e4b3 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -6,7 +6,7 @@ summary: Guide to building a custom adapter Build a custom adapter to connect Paperclip to any agent runtime. -If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. +If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. ## Package Structure diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md new file mode 100644 index 00000000..51380b05 --- /dev/null +++ b/docs/adapters/gemini-local.md @@ -0,0 +1,45 @@ +--- +title: Gemini Local +summary: Gemini CLI local adapter setup and configuration +--- + +The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing. + +## Prerequisites + +- Gemini CLI installed (`gemini` command available) +- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured + +## Configuration Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Gemini model to use. Defaults to `auto`. | +| `promptTemplate` | string | No | Prompt used for all runs | +| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation | + +## Session Persistence + +The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context. + +Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead. + +If resume fails with an unknown session error, the adapter automatically retries with a fresh session. + +## Skills Injection + +The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten. + +## Environment Test + +Use the "Test Environment" button in the UI to validate the adapter config. It checks: + +- Gemini CLI is installed and accessible +- Working directory is absolute and available (auto-created if missing and permitted) +- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`) +- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 4237f87f..44b879d7 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | @@ -54,7 +55,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/docs/api/issues.md b/docs/api/issues.md index 1318b171..ff4878df 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -1,9 +1,9 @@ --- title: Issues -summary: Issue CRUD, checkout/release, comments, and attachments +summary: Issue CRUD, checkout/release, comments, documents, and attachments --- -Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments. +Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments. ## List Issues @@ -29,6 +29,12 @@ GET /api/issues/{issueId} Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals). +The response also includes: + +- `planDocument`: the full text of the issue document with key `plan`, when present +- `documentSummaries`: metadata for all linked issue documents +- `legacyPlanDocument`: a read-only fallback when the description still contains an old `` block + ## Create Issue ``` @@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments @-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent. +## Documents + +Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`. + +### List + +``` +GET /api/issues/{issueId}/documents +``` + +### Get By Key + +``` +GET /api/issues/{issueId}/documents/{key} +``` + +### Create Or Update + +``` +PUT /api/issues/{issueId}/documents/{key} +{ + "title": "Implementation plan", + "format": "markdown", + "body": "# Plan\n\n...", + "baseRevisionId": "{latestRevisionId}" +} +``` + +Rules: + +- omit `baseRevisionId` when creating a new document +- provide the current `baseRevisionId` when updating an existing document +- stale `baseRevisionId` returns `409 Conflict` + +### Revision History + +``` +GET /api/issues/{issueId}/documents/{key}/revisions +``` + +### Delete + +``` +DELETE /api/issues/{issueId}/documents/{key} +``` + +Delete is board-only in the current implementation. + ## Attachments ### Upload diff --git a/docs/plans/2026-03-13-issue-documents-plan.md b/docs/plans/2026-03-13-issue-documents-plan.md new file mode 100644 index 00000000..c8a5dd1c --- /dev/null +++ b/docs/plans/2026-03-13-issue-documents-plan.md @@ -0,0 +1,569 @@ +# Issue Documents Plan + +Status: Draft +Owner: Backend + UI + Agent Protocol +Date: 2026-03-13 +Primary issue: `PAP-448` + +## Summary + +Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues. + +The first required convention is a document with key `plan`. + +This solves the immediate workflow problem in `PAP-448`: + +- plans should stop living inside issue descriptions as `` blocks +- agents and board users should be able to create/update issue documents directly +- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents +- issue detail should render documents under the description + +This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets. + +## Recommended Product Shape + +### Documents vs attachments vs artifacts + +- **Documents**: editable text content with stable keys and revision history. +- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`). +- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files. + +Recommendation: + +- implement **issue documents now** +- keep existing attachments as-is +- defer full artifact unification until there is a second real consumer beyond issue documents + attachments + +This keeps `PAP-448` focused while still fitting the larger artifact direction. + +## Goals + +1. Give issues first-class keyed documents, starting with `plan`. +2. Make documents editable by board users and same-company agents with issue access. +3. Preserve change history with append-only revisions. +4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats. +5. Replace the current ``-in-description convention in skills/docs. +6. Keep the design compatible with a future artifact/deliverables layer. + +## Non-Goals + +- full collaborative doc editing +- binary-file version history +- browser IDE or workspace editor +- full artifact-system implementation in the same change +- generalized polymorphic relations for every entity type on day one + +## Product Decisions + +### 1. Keyed issue documents + +Each issue can have multiple documents. Each document relation has a stable key: + +- `plan` +- `design` +- `notes` +- `report` +- custom keys later + +Key rules: + +- unique per issue, case-insensitive +- normalized to lowercase slug form +- machine-oriented and stable +- title is separate and user-facing + +The `plan` key is conventional and reserved by Paperclip workflow/docs. + +### 2. Text-first v1 + +V1 documents should be text-first, not arbitrary blobs. + +Recommended supported formats: + +- `markdown` +- `plain_text` +- `json` +- `html` + +Recommendation: + +- optimize UI for `markdown` +- allow raw editing for the others +- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents + +### 3. Revision model + +Every document update creates a new immutable revision. + +The current document row stores the latest snapshot for fast reads. + +### 4. Concurrency model + +Do not use silent last-write-wins. + +Updates should include `baseRevisionId`: + +- create: no base revision required +- update: `baseRevisionId` must match current latest revision +- mismatch: return `409 Conflict` + +This is important because both board users and agents may edit the same document. + +### 5. Issue fetch behavior + +`GET /api/issues/:id` should include: + +- full `planDocument` when a `plan` document exists +- `documentSummaries` for all linked documents + +It should not inline every document body by default. + +This keeps issue fetches useful for agents without making every issue payload unbounded. + +### 6. Legacy `` compatibility + +If an issue has no `plan` document but its description contains a legacy `` block: + +- expose that as a legacy read-only fallback in API/UI +- mark it as legacy/synthetic +- prefer a real `plan` document when both exist + +Recommendation: + +- do not auto-rewrite old issue descriptions in the first rollout +- provide an explicit import/migrate path later + +## Proposed Data Model + +Recommendation: make documents first-class, but keep issue linkage explicit via a join table. + +This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later. + +## Tables + +### `documents` + +Canonical text document record. + +Suggested columns: + +- `id` +- `company_id` +- `title` +- `format` +- `latest_body` +- `latest_revision_id` +- `latest_revision_number` +- `created_by_agent_id` +- `created_by_user_id` +- `updated_by_agent_id` +- `updated_by_user_id` +- `created_at` +- `updated_at` + +### `document_revisions` + +Append-only history. + +Suggested columns: + +- `id` +- `company_id` +- `document_id` +- `revision_number` +- `body` +- `change_summary` +- `created_by_agent_id` +- `created_by_user_id` +- `created_at` + +Constraints: + +- unique `(document_id, revision_number)` + +### `issue_documents` + +Issue relation + workflow key. + +Suggested columns: + +- `id` +- `company_id` +- `issue_id` +- `document_id` +- `key` +- `created_at` +- `updated_at` + +Constraints: + +- unique `(company_id, issue_id, key)` +- unique `(document_id)` to keep one issue relation per document in v1 + +## Why not use `assets` for this? + +Because `assets` solves blob storage, not: + +- stable keyed semantics like `plan` +- inline text editing +- revision history +- optimistic concurrency +- cheap inclusion in `GET /issues/:id` + +Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model. + +## Shared Types and API Contract + +## New shared types + +Add: + +- `DocumentFormat` +- `IssueDocument` +- `IssueDocumentSummary` +- `DocumentRevision` + +Recommended `IssueDocument` shape: + +```ts +type DocumentFormat = "markdown" | "plain_text" | "json" | "html"; + +interface IssueDocument { + id: string; + companyId: string; + issueId: string; + key: string; + title: string | null; + format: DocumentFormat; + body: string; + latestRevisionId: string; + latestRevisionNumber: number; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} +``` + +Recommended `IssueDocumentSummary` shape: + +```ts +interface IssueDocumentSummary { + id: string; + key: string; + title: string | null; + format: DocumentFormat; + latestRevisionId: string; + latestRevisionNumber: number; + updatedAt: Date; +} +``` + +## Issue type enrichment + +Extend `Issue` with: + +```ts +interface Issue { + ... + planDocument?: IssueDocument | null; + documentSummaries?: IssueDocumentSummary[]; + legacyPlanDocument?: { + key: "plan"; + body: string; + source: "issue_description"; + } | null; +} +``` + +This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches. + +## API endpoints + +Recommended endpoints: + +- `GET /api/issues/:issueId/documents` +- `GET /api/issues/:issueId/documents/:key` +- `PUT /api/issues/:issueId/documents/:key` +- `GET /api/issues/:issueId/documents/:key/revisions` +- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1 + +Recommended `PUT` body: + +```ts +{ + title?: string | null; + format: "markdown" | "plain_text" | "json" | "html"; + body: string; + changeSummary?: string | null; + baseRevisionId?: string | null; +} +``` + +Behavior: + +- missing document + no `baseRevisionId`: create +- existing document + matching `baseRevisionId`: update +- existing document + stale `baseRevisionId`: `409` + +## Authorization and invariants + +- all document records are company-scoped +- issue relation must belong to same company +- board access follows existing issue access rules +- agent access follows existing same-company issue access rules +- every mutation writes activity log entries + +Recommended delete rule for v1: + +- board can delete documents +- agents can create/update, but not delete + +That keeps automated systems from removing canonical docs too easily. + +## UI Plan + +## Issue detail + +Add a new **Documents** section directly under the issue description. + +Recommended behavior: + +- show `plan` first when present +- show other documents below it +- render a gist-like header: + - key + - title + - last updated metadata + - revision number +- support inline edit +- support create new document by key +- support revision history drawer or sheet + +Recommended presentation order: + +1. Description +2. Documents +3. Attachments +4. Comments / activity / sub-issues + +This matches the request that documents live under the description while still leaving attachments available. + +## Editing UX + +Recommendation: + +- use markdown preview + raw edit toggle for markdown docs +- use raw textarea editor for non-markdown docs in v1 +- show explicit save conflicts on `409` +- show a clear empty state: "No documents yet" + +## Legacy plan rendering + +If there is no stored `plan` document but legacy `` exists: + +- show it in the Documents section +- mark it `Legacy plan from description` +- offer create/import in a later pass + +## Agent Protocol and Skills + +Update the Paperclip agent workflow so planning no longer edits the issue description. + +Required changes: + +- update `skills/paperclip/SKILL.md` +- replace the `` instructions with document creation/update instructions +- document the new endpoints in `docs/api/issues.md` +- update any internal planning docs that still teach inline `` blocks + +New rule: + +- when asked to make a plan for an issue, create or update the issue document with key `plan` +- leave a comment that the plan document was created/updated +- do not mark the issue done + +## Relationship to the Artifact Plan + +This work should explicitly feed the broader artifact/deliverables direction. + +Recommendation: + +- keep documents as their own primitive in this change +- add `document` to any future `ArtifactKind` +- later build a deliverables read-model that aggregates: + - issue documents + - issue attachments + - preview URLs + - workspace-file references + +The artifact proposal currently has no explicit `document` kind. It should. + +Recommended future shape: + +```ts +type ArtifactKind = + | "document" + | "attachment" + | "workspace_file" + | "preview" + | "report_link"; +``` + +## Implementation Phases + +## Phase 1: Shared contract and schema + +Files: + +- `packages/db/src/schema/documents.ts` +- `packages/db/src/schema/document_revisions.ts` +- `packages/db/src/schema/issue_documents.ts` +- `packages/db/src/schema/index.ts` +- `packages/db/src/migrations/*` +- `packages/shared/src/types/issue.ts` +- `packages/shared/src/validators/issue.ts` or new document validator file +- `packages/shared/src/index.ts` + +Acceptance: + +- schema enforces one key per issue +- revisions are append-only +- shared types expose plan/document fields on issue fetch + +## Phase 2: Server services and routes + +Files: + +- `server/src/services/issues.ts` or `server/src/services/documents.ts` +- `server/src/routes/issues.ts` +- `server/src/services/activity.ts` callsites + +Behavior: + +- list/get/upsert/delete documents +- revision listing +- `GET /issues/:id` returns `planDocument` + `documentSummaries` +- company boundary checks match issue routes + +Acceptance: + +- agents and board can fetch/update same-company issue documents +- stale edits return `409` +- activity timeline shows document changes + +## Phase 3: UI issue documents surface + +Files: + +- `ui/src/api/issues.ts` +- `ui/src/lib/queryKeys.ts` +- `ui/src/pages/IssueDetail.tsx` +- new reusable document UI component if needed + +Behavior: + +- render plan + documents under description +- create/update by key +- open revision history +- show conflicts/errors clearly + +Acceptance: + +- board can create a `plan` doc from issue detail +- updated plan appears immediately +- issue detail no longer depends on description-embedded `` + +## Phase 4: Skills/docs migration + +Files: + +- `skills/paperclip/SKILL.md` +- `docs/api/issues.md` +- `doc/SPEC-implementation.md` +- relevant plan/docs that mention `` + +Acceptance: + +- planning guidance references issue documents, not inline issue description tags +- API docs describe the new document endpoints and issue payload additions + +## Phase 5: Legacy compatibility and follow-up + +Behavior: + +- read legacy `` blocks as fallback +- optionally add explicit import/migration command later + +Follow-up, not required for first merge: + +- deliverables/artifact read-model +- project/company documents +- comment-linked documents +- diff view between revisions + +## Test Plan + +### Server + +- document create/read/update/delete lifecycle +- revision numbering +- `baseRevisionId` conflict handling +- company boundary enforcement +- agent vs board authorization +- issue fetch includes `planDocument` and document summaries +- legacy `` fallback behavior +- activity log mutation coverage + +### UI + +- issue detail shows plan document +- create/update flows invalidate queries correctly +- conflict and validation errors are surfaced +- legacy plan fallback renders correctly + +### Verification + +Run before implementation is declared complete: + +```sh +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +## Open Questions + +1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred? + Recommendation: allow all four in API, optimize UI for markdown only. + +2. Should agents be allowed to create arbitrary keys, or only conventional keys? + Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only. + +3. Should delete exist in v1? + Recommendation: yes, but board-only. + +4. Should legacy `` blocks ever be auto-migrated? + Recommendation: no automatic mutation in the first rollout. + +5. Should documents appear inside a future Deliverables section or remain a top-level Issue section? + Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added. + +## Final Recommendation + +Ship **issue documents** as a focused, text-first primitive now. + +Do not try to solve full artifact unification in the same implementation. + +Use: + +- first-class document tables +- issue-level keyed linkage +- append-only revisions +- `planDocument` embedded in normal issue fetches +- legacy `` fallback +- skill/docs migration away from description-embedded plans + +This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward. diff --git a/package.json b/package.json index 45c02b8b..61f9968e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "node scripts/dev-runner.mjs watch", - "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", + "dev:watch": "node scripts/dev-runner.mjs watch", "dev:once": "node scripts/dev-runner.mjs dev", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", @@ -18,17 +18,25 @@ "db:backup": "./scripts/backup-db.sh", "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", + "release:start": "./scripts/release-start.sh", "release": "./scripts/release.sh", + "release:preflight": "./scripts/release-preflight.sh", + "release:github": "./scripts/create-github-release.sh", + "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", "version-packages": "changeset version", "check:tokens": "node scripts/check-forbidden-tokens.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", - "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh" + "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", + "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", + "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" }, "devDependencies": { "@changesets/cli": "^2.30.0", + "cross-env": "^10.1.0", + "@playwright/test": "^1.58.2", "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index 6fbad4b9..76cabbd7 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,5 +1,17 @@ # @paperclipai/adapter-utils +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 118eb895..3a908ee5 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-utils", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 83605307..56579022 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -3,6 +3,7 @@ export type { AdapterRuntime, UsageSummary, AdapterBillingType, + AdapterRuntimeServiceReport, AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, @@ -21,3 +22,9 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export { + REDACTED_HOME_PATH_USER, + redactHomePathUserSegments, + redactHomePathUserSegmentsInValue, + redactTranscriptEntryPaths, +} from "./log-redaction.js"; diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts new file mode 100644 index 00000000..037e279e --- /dev/null +++ b/packages/adapter-utils/src/log-redaction.ts @@ -0,0 +1,81 @@ +import type { TranscriptEntry } from "./types.js"; + +export const REDACTED_HOME_PATH_USER = "[]"; + +const HOME_PATH_PATTERNS = [ + { + regex: /\/Users\/[^/\\\s]+/g, + replace: `/Users/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /\/home\/[^/\\\s]+/g, + replace: `/home/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g, + replace: `$1${REDACTED_HOME_PATH_USER}`, + }, +] as const; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function redactHomePathUserSegments(text: string): string { + let result = text; + for (const pattern of HOME_PATH_PATTERNS) { + result = result.replace(pattern.regex, pattern.replace); + } + return result; +} + +export function redactHomePathUserSegmentsInValue(value: T): T { + if (typeof value === "string") { + return redactHomePathUserSegments(value) as T; + } + if (Array.isArray(value)) { + return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T; + } + if (!isPlainObject(value)) { + return value; + } + + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = redactHomePathUserSegmentsInValue(entry); + } + return redacted as T; +} + +export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry { + switch (entry.kind) { + case "assistant": + case "thinking": + case "user": + case "stderr": + case "system": + case "stdout": + return { ...entry, text: redactHomePathUserSegments(entry.text) }; + case "tool_call": + return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) }; + case "tool_result": + return { ...entry, content: redactHomePathUserSegments(entry.content) }; + case "init": + return { + ...entry, + model: redactHomePathUserSegments(entry.model), + sessionId: redactHomePathUserSegments(entry.sessionId), + }; + case "result": + return { + ...entry, + text: redactHomePathUserSegments(entry.text), + subtype: redactHomePathUserSegments(entry.subtype), + errors: entry.errors.map((error) => redactHomePathUserSegments(error)), + }; + default: + return entry; + } +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 76efba86..52e52b4c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -15,6 +15,11 @@ interface RunningProcess { graceSec: number; } +interface SpawnTarget { + command: string; + args: string[]; +} + type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; on( @@ -27,6 +32,23 @@ export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; +const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ + "../../skills", + "../../../../../skills", +]; + +export interface PaperclipSkillEntry { + name: string; + source: string; +} + +function normalizePathSlashes(value: string): string { + return value.replaceAll("\\", "/"); +} + +function isMaintainerOnlySkillTarget(candidate: string): boolean { + return normalizePathSlashes(candidate).includes("/.agents/skills/"); +} export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -90,6 +112,16 @@ export function renderTemplate(template: string, data: Record) return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); } +export function joinPromptSections( + sections: Array, + separator = "\n\n", +) { + return sections + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + .join(separator); +} + export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { @@ -125,6 +157,78 @@ export function defaultPathForPlatform() { return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; } +function windowsPathExts(env: NodeJS.ProcessEnv): string[] { + return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); +} + +async function pathExists(candidate: string) { + try { + await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + const hasPathSeparator = command.includes("/") || command.includes("\\"); + if (hasPathSeparator) { + const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); + return (await pathExists(absolute)) ? absolute : null; + } + + const pathValue = env.PATH ?? env.Path ?? ""; + const delimiter = process.platform === "win32" ? ";" : ":"; + const dirs = pathValue.split(delimiter).filter(Boolean); + const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; + const hasExtension = process.platform === "win32" && path.extname(command).length > 0; + + for (const dir of dirs) { + const candidates = + process.platform === "win32" + ? hasExtension + ? [path.join(dir, command)] + : exts.map((ext) => path.join(dir, `${command}${ext}`)) + : [path.join(dir, command)]; + for (const candidate of candidates) { + if (await pathExists(candidate)) return candidate; + } + } + + return null; +} + +function quoteForCmd(arg: string) { + if (!arg.length) return '""'; + const escaped = arg.replace(/"/g, '""'); + return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; +} + +async function resolveSpawnTarget( + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, +): Promise { + const resolved = await resolveCommandPath(command, cwd, env); + const executable = resolved ?? command; + + if (process.platform !== "win32") { + return { command: executable, args }; + } + + if (/\.(cmd|bat)$/i.test(executable)) { + const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; + const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); + return { + command: shell, + args: ["/d", "/s", "/c", commandLine], + }; + } + + return { command: executable, args }; +} + export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (typeof env.PATH === "string" && env.PATH.length > 0) return env; if (typeof env.Path === "string" && env.Path.length > 0) return env; @@ -168,37 +272,143 @@ export async function ensureAbsoluteDirectory( } } -export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { - const hasPathSeparator = command.includes("/") || command.includes("\\"); - if (hasPathSeparator) { - const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); - try { - await fs.access(absolute, fsConstants.X_OK); - } catch { - throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); - } - return; +export async function resolvePaperclipSkillsDir( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const candidates = [ + ...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), + ...additionalCandidates.map((candidate) => path.resolve(candidate)), + ]; + const seenRoots = new Set(); + + for (const root of candidates) { + if (seenRoots.has(root)) continue; + seenRoots.add(root); + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (isDirectory) return root; } - const pathValue = env.PATH ?? env.Path ?? ""; - const delimiter = process.platform === "win32" ? ";" : ":"; - const dirs = pathValue.split(delimiter).filter(Boolean); - const windowsExt = process.platform === "win32" - ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") - : [""]; + return null; +} - for (const dir of dirs) { - for (const ext of windowsExt) { - const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command); - try { - await fs.access(candidate, fsConstants.X_OK); - return; - } catch { - // continue scanning PATH +export async function listPaperclipSkillEntries( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates); + if (!root) return []; + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: path.join(root, entry.name), + })); + } catch { + return []; + } +} + +export async function readPaperclipSkillMarkdown( + moduleDir: string, + skillName: string, +): Promise { + const normalized = skillName.trim().toLowerCase(); + if (!normalized) return null; + + const entries = await listPaperclipSkillEntries(moduleDir); + const match = entries.find((entry) => entry.name === normalized); + if (!match) return null; + + try { + return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8"); + } catch { + return null; + } +} + +export async function ensurePaperclipSkillSymlink( + source: string, + target: string, + linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => + fs.symlink(linkSource, linkTarget), +): Promise<"created" | "repaired" | "skipped"> { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await linkSkill(source, target); + return "created"; + } + + if (!existing.isSymbolicLink()) { + return "skipped"; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return "skipped"; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) { + return "skipped"; + } + + const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); + if (linkedPathExists) { + return "skipped"; + } + + await fs.unlink(target); + await linkSkill(source, target); + return "repaired"; +} + +export async function removeMaintainerOnlySkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, +): Promise { + const allowed = new Set(Array.from(allowedSkillNames)); + try { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }); + const removed: string[] = []; + for (const entry of entries) { + if (allowed.has(entry.name)) continue; + + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + if ( + !isMaintainerOnlySkillTarget(linkedPath) && + !isMaintainerOnlySkillTarget(resolvedLinkedPath) + ) { + continue; } - } - } + await fs.unlink(target); + removed.push(entry.name); + } + + return removed; + } catch { + return []; + } +} + +export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { + const resolved = await resolveCommandPath(command, cwd, env); + if (resolved) return; + if (command.includes("/") || command.includes("\\")) { + const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); + throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); + } throw new Error(`Command not found in PATH: "${command}"`); } @@ -219,79 +429,100 @@ export async function runChildProcess( const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); return new Promise((resolve, reject) => { - const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env }); - const child = spawn(command, args, { - cwd: opts.cwd, - env: mergedEnv, - shell: false, - stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }) as ChildProcessWithEvents; + const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; - if (opts.stdin != null && child.stdin) { - child.stdin.write(opts.stdin); - child.stdin.end(); + // Strip Claude Code nesting-guard env vars so spawned `claude` processes + // don't refuse to start with "cannot be launched inside another session". + // These vars leak in when the Paperclip server itself is started from + // within a Claude Code session (e.g. `npx paperclipai run` in a terminal + // owned by Claude Code) or when cron inherits a contaminated shell env. + const CLAUDE_CODE_NESTING_VARS = [ + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_SESSION", + "CLAUDE_CODE_PARENT_SESSION", + ] as const; + for (const key of CLAUDE_CODE_NESTING_VARS) { + delete rawMerged[key]; } - runningProcesses.set(runId, { child, graceSec: opts.graceSec }); + const mergedEnv = ensurePathInEnv(rawMerged); + void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) + .then((target) => { + const child = spawn(target.command, target.args, { + cwd: opts.cwd, + env: mergedEnv, + shell: false, + stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], + }) as ChildProcessWithEvents; - let timedOut = false; - let stdout = ""; - let stderr = ""; - let logChain: Promise = Promise.resolve(); + if (opts.stdin != null && child.stdin) { + child.stdin.write(opts.stdin); + child.stdin.end(); + } - const timeout = - opts.timeoutSec > 0 - ? setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } - }, Math.max(1, opts.graceSec) * 1000); - }, opts.timeoutSec * 1000) - : null; + runningProcesses.set(runId, { child, graceSec: opts.graceSec }); - child.stdout?.on("data", (chunk: unknown) => { - const text = String(chunk); - stdout = appendWithCap(stdout, text); - logChain = logChain - .then(() => opts.onLog("stdout", text)) - .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); - }); + let timedOut = false; + let stdout = ""; + let stderr = ""; + let logChain: Promise = Promise.resolve(); - child.stderr?.on("data", (chunk: unknown) => { - const text = String(chunk); - stderr = appendWithCap(stderr, text); - logChain = logChain - .then(() => opts.onLog("stderr", text)) - .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); - }); + const timeout = + opts.timeoutSec > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, Math.max(1, opts.graceSec) * 1000); + }, opts.timeoutSec * 1000) + : null; - child.on("error", (err: Error) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - const errno = (err as NodeJS.ErrnoException).code; - const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; - const msg = - errno === "ENOENT" - ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` - : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; - reject(new Error(msg)); - }); - - child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { - if (timeout) clearTimeout(timeout); - runningProcesses.delete(runId); - void logChain.finally(() => { - resolve({ - exitCode: code, - signal, - timedOut, - stdout, - stderr, + child.stdout?.on("data", (chunk: unknown) => { + const text = String(chunk); + stdout = appendWithCap(stdout, text); + logChain = logChain + .then(() => opts.onLog("stdout", text)) + .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); }); - }); - }); + + child.stderr?.on("data", (chunk: unknown) => { + const text = String(chunk); + stderr = appendWithCap(stderr, text); + logChain = logChain + .then(() => opts.onLog("stderr", text)) + .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); + }); + + child.on("error", (err: Error) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + const errno = (err as NodeJS.ErrnoException).code; + const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; + const msg = + errno === "ENOENT" + ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` + : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`; + reject(new Error(msg)); + }); + + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + if (timeout) clearTimeout(timeout); + runningProcesses.delete(runId); + void logChain.finally(() => { + resolve({ + exitCode: code, + signal, + timedOut, + stdout, + stderr, + }); + }); + }); + }) + .catch(reject); }); } diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index bf9b7748..df0d075a 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -32,6 +32,27 @@ export interface UsageSummary { export type AdapterBillingType = "api" | "subscription" | "unknown"; +export interface AdapterRuntimeServiceReport { + id?: string | null; + projectId?: string | null; + projectWorkspaceId?: string | null; + issueId?: string | null; + scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent"; + scopeId?: string | null; + serviceName: string; + status?: "starting" | "running" | "stopped" | "failed"; + lifecycle?: "shared" | "ephemeral"; + reuseKey?: string | null; + command?: string | null; + cwd?: string | null; + port?: number | null; + url?: string | null; + providerRef?: string | null; + ownerAgentId?: string | null; + stopPolicy?: Record | null; + healthStatus?: "unknown" | "healthy" | "unhealthy"; +} + export interface AdapterExecutionResult { exitCode: number | null; signal: string | null; @@ -51,8 +72,17 @@ export interface AdapterExecutionResult { billingType?: AdapterBillingType | null; costUsd?: number | null; resultJson?: Record | null; + runtimeServices?: AdapterRuntimeServiceReport[]; summary?: string | null; clearSession?: boolean; + question?: { + prompt: string; + choices: Array<{ + key: string; + label: string; + description?: string; + }>; + } | null; } export interface AdapterSessionCodec { @@ -69,6 +99,7 @@ export interface AdapterInvocationMeta { commandNotes?: string[]; env?: Record; prompt?: string; + promptMetrics?: Record; context?: Record; } @@ -167,7 +198,7 @@ export type TranscriptEntry = | { kind: "assistant"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } - | { kind: "tool_call"; ts: string; name: string; input: unknown } + | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } @@ -208,6 +239,12 @@ export interface CreateConfigValues { envBindings: Record; url: string; bootstrapPrompt: string; + payloadTemplateJson?: string; + workspaceStrategyType?: string; + workspaceBaseRef?: string; + workspaceBranchTemplate?: string; + worktreeParentDir?: string; + runtimeServicesJson?: string; maxTurnsPerRun: number; heartbeatEnabled: boolean; intervalSec: number; diff --git a/packages/adapter-utils/tsconfig.json b/packages/adapter-utils/tsconfig.json index a086b149..5a24989c 100644 --- a/packages/adapter-utils/tsconfig.json +++ b/packages/adapter-utils/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index 63bcdc4d..b9035585 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,5 +1,24 @@ # @paperclipai/adapter-claude-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index c999013d..35a6d9ed 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-claude-local", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 481c305d..b28ae180 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -3,6 +3,8 @@ export const label = "Claude Code (local)"; export const models = [ { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; @@ -23,8 +25,13 @@ Core fields: - command (string, optional): defaults to "claude" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables +- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } +- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env Operational fields: - timeoutSec (number, optional): run timeout in seconds - graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 32fa6bd4..dfcd1173 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -115,14 +116,29 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => typeof value === "object" && value !== null, ) : []; + const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents) + ? context.paperclipRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.paperclipRuntimeServices) + ? context.paperclipRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; @@ -183,6 +199,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } + if (runtimeServiceIntents.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; @@ -331,7 +368,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildClaudeArgs = (resumeSessionId: string | null) => { const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; @@ -384,6 +439,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { return env; } +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + export function buildClaudeLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; if (v.model) ac.model = v.model; if (v.thinkingEffort) ac.effort = v.thinkingEffort; if (v.chrome) ac.chrome = true; @@ -70,6 +83,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; + if (v.workspaceStrategyType === "git_worktree") { + ac.workspaceStrategy = { + type: "git_worktree", + ...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}), + ...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}), + ...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}), + }; + } + const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? ""); + if (runtimeServices && Array.isArray(runtimeServices.services)) { + ac.workspaceRuntime = runtimeServices; + } if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); return ac; diff --git a/packages/adapters/claude-local/src/ui/parse-stdout.ts b/packages/adapters/claude-local/src/ui/parse-stdout.ts index 51c2b3c6..f7bd1b2a 100644 --- a/packages/adapters/claude-local/src/ui/parse-stdout.ts +++ b/packages/adapters/claude-local/src/ui/parse-stdout.ts @@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry kind: "tool_call", ts, name: typeof block.name === "string" ? block.name : "unknown", + toolUseId: + typeof block.id === "string" + ? block.id + : typeof block.tool_use_id === "string" + ? block.tool_use_id + : undefined, input: block.input ?? {}, }); } diff --git a/packages/adapters/claude-local/tsconfig.json b/packages/adapters/claude-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/claude-local/tsconfig.json +++ b/packages/adapters/claude-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/codex-local/CHANGELOG.md b/packages/adapters/codex-local/CHANGELOG.md index dd1bc70e..45c143e7 100644 --- a/packages/adapters/codex-local/CHANGELOG.md +++ b/packages/adapters/codex-local/CHANGELOG.md @@ -1,5 +1,24 @@ # @paperclipai/adapter-codex-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index e6853aa7..4b28c729 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-codex-local", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index f09e50d9..ac0726ad 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -31,6 +31,8 @@ Core fields: - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables +- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } +- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env Operational fields: - timeoutSec (number, optional): run timeout in seconds @@ -40,4 +42,5 @@ Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). - Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). +- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts new file mode 100644 index 00000000..de037d6a --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -0,0 +1,101 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; +const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; +const SYMLINKED_SHARED_FILES = ["auth.json"] as const; + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string { + const fromEnv = nonEmpty(env.CODEX_HOME); + if (fromEnv) return path.resolve(fromEnv); + return path.join(os.homedir(), ".codex"); +} + +function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { + return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); +} + +function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null { + if (!isWorktreeMode(env)) return null; + const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); + if (!paperclipHome) return null; + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID); + if (instanceId) { + return path.resolve(paperclipHome, "instances", instanceId, "codex-home"); + } + return path.resolve(paperclipHome, "codex-home"); +} + +async function ensureParentDir(target: string): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); +} + +async function ensureSymlink(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await ensureParentDir(target); + await fs.symlink(source, target); + return; + } + + if (!existing.isSymbolicLink()) { + return; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) return; + + await fs.unlink(target); + await fs.symlink(source, target); +} + +async function ensureCopiedFile(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (existing) return; + await ensureParentDir(target); + await fs.copyFile(source, target); +} + +export async function prepareWorktreeCodexHome( + env: NodeJS.ProcessEnv, + onLog: AdapterExecutionContext["onLog"], +): Promise { + const targetHome = resolveWorktreeCodexHomeDir(env); + if (!targetHome) return null; + + const sourceHome = resolveCodexHomeDir(env); + if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; + + await fs.mkdir(targetHome, { recursive: true }); + + for (const name of SYMLINKED_SHARED_FILES) { + const source = path.join(sourceHome, name); + if (!(await pathExists(source))) continue; + await ensureSymlink(path.join(targetHome, name), source); + } + + for (const name of COPIED_SHARED_FILES) { + const source = path.join(sourceHome, name); + if (!(await pathExists(source))) continue; + await ensureCopiedFile(path.join(targetHome, name), source); + } + + await onLog( + "stderr", + `[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`, + ); + return targetHome; +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f9d871c9..e1718cc1 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; @@ -13,17 +12,18 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, + joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; const CODEX_ROLLOUT_NOISE_RE = /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; @@ -61,39 +61,95 @@ function resolveCodexBillingType(env: Record): "api" | "subscrip return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } -function codexHomeDir(): string { - const fromEnv = process.env.CODEX_HOME; - if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim(); - return path.join(os.homedir(), ".codex"); +async function isLikelyPaperclipRepoRoot(candidate: string): Promise { + const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([ + pathExists(path.join(candidate, "pnpm-workspace.yaml")), + pathExists(path.join(candidate, "package.json")), + pathExists(path.join(candidate, "server")), + pathExists(path.join(candidate, "packages", "adapter-utils")), + ]); + + return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir; } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; +async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise { + if (path.basename(candidate) !== skillName) return false; + const skillsRoot = path.dirname(candidate); + if (path.basename(skillsRoot) !== "skills") return false; + if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false; + + let cursor = path.dirname(skillsRoot); + for (let depth = 0; depth < 6; depth += 1) { + if (await isLikelyPaperclipRepoRoot(cursor)) return true; + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; } - return null; + + return false; } -async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; +type EnsureCodexSkillsInjectedOptions = { + skillsHome?: string; + skillsEntries?: Awaited>; + linkSkill?: (source: string, target: string) => Promise; +}; - const skillsHome = path.join(codexHomeDir(), "skills"); +export async function ensureCodexSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + options: EnsureCodexSkillsInjectedOptions = {}, +) { + const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; + + const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`, + ); + } + const linkSkill = options.linkSkill; + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const existing = await fs.lstat(target).catch(() => null); + if (existing?.isSymbolicLink()) { + const linkedPath = await fs.readlink(target).catch(() => null); + const resolvedLinkedPath = linkedPath + ? path.resolve(path.dirname(target), linkedPath) + : null; + if ( + resolvedLinkedPath && + resolvedLinkedPath !== entry.source && + (await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name)) + ) { + await fs.unlink(target); + if (linkSkill) { + await linkSkill(entry.source, target); + } else { + await fs.symlink(entry.source, target); + } + await onLog( + "stderr", + `[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`, + ); + continue; + } + } + + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( @@ -126,24 +182,52 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, ) : []; + const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents) + ? context.paperclipRuntimeServiceIntents.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimeServices = Array.isArray(context.paperclipRuntimeServices) + ? context.paperclipRuntimeServices.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, ""); const configuredCwd = asString(config.cwd, ""); const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - await ensureCodexSkillsInjected(onLog); const envConfig = parseObject(config.env); + const configuredCodexHome = + typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0 + ? path.resolve(envConfig.CODEX_HOME.trim()) + : null; + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + const preparedWorktreeCodexHome = + configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); + const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; + await ensureCodexSkillsInjected( + onLog, + effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {}, + ); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; + if (effectiveCodexHome) { + env.CODEX_HOME = effectiveCodexHome; + } env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || @@ -192,6 +276,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } + if (runtimeServiceIntents.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } @@ -238,6 +343,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["exec", "--json"]; @@ -306,6 +432,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { return env; } +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + export function buildCodexLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL; if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort; ac.timeoutSec = 0; @@ -76,6 +89,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record, ts: string): Transcr .filter((change): change is Record => Boolean(change)) .map((change) => { const kind = asString(change.kind, "update"); - const path = asString(change.path, "unknown"); + const path = redactHomePathUserSegments(asString(change.path, "unknown")); return `${kind} ${path}`; }); @@ -125,13 +131,13 @@ function parseCodexItem( if (itemType === "agent_message") { const text = asString(item.text); - if (text) return [{ kind: "assistant", ts, text }]; + if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }]; return []; } if (itemType === "reasoning") { const text = asString(item.text); - if (text) return [{ kind: "thinking", ts, text }]; + if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }]; return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; } @@ -147,8 +153,9 @@ function parseCodexItem( return [{ kind: "tool_call", ts, - name: asString(item.name, "unknown"), - input: item.input ?? {}, + name: redactHomePathUserSegments(asString(item.name, "unknown")), + toolUseId: asString(item.id), + input: redactHomePathUserSegmentsInValue(item.input ?? {}), }]; } @@ -160,24 +167,28 @@ function parseCodexItem( asString(item.result) || stringifyUnknown(item.content ?? item.output ?? item.result); const isError = item.is_error === true || asString(item.status) === "error"; - return [{ kind: "tool_result", ts, toolUseId, content, isError }]; + return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }]; } if (itemType === "error" && phase === "completed") { const text = errorText(item.message ?? item.error ?? item); - return [{ kind: "stderr", ts, text: text || "error" }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }]; } const id = asString(item.id); const status = asString(item.status); const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); - return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }]; + return [{ + kind: "system", + ts, + text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`), + }]; } export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { const parsed = asRecord(safeJsonParse(line)); if (!parsed) { - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } const type = asString(parsed.type); @@ -187,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "init", ts, - model: asString(parsed.model, "codex"), - sessionId: threadId, + model: redactHomePathUserSegments(asString(parsed.model, "codex")), + sessionId: redactHomePathUserSegments(threadId), }]; } @@ -210,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype), + subtype: redactHomePathUserSegments(asString(parsed.subtype)), isError: parsed.is_error === true, errors: Array.isArray(parsed.errors) - ? parsed.errors.map(errorText).filter(Boolean) + ? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean) : [], }]; } @@ -232,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype, "turn.failed"), + subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")), isError: true, - errors: message ? [message] : [], + errors: message ? [redactHomePathUserSegments(message)] : [], }]; } if (type === "error") { const message = errorText(parsed.message ?? parsed.error ?? parsed); - return [{ kind: "stderr", ts, text: message || line }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } diff --git a/packages/adapters/codex-local/tsconfig.json b/packages/adapters/codex-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/codex-local/tsconfig.json +++ b/packages/adapters/codex-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/cursor-local/CHANGELOG.md b/packages/adapters/cursor-local/CHANGELOG.md index d0147ff1..df26ccde 100644 --- a/packages/adapters/cursor-local/CHANGELOG.md +++ b/packages/adapters/cursor-local/CHANGELOG.md @@ -1,5 +1,24 @@ # @paperclipai/adapter-cursor-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 4ef66052..3561f0ff 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-cursor-local", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 662bc8a7..5845fba8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -56,7 +56,7 @@ Use when: - You want structured stream output in run logs via --output-format stream-json Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Cursor Agent CLI is not installed on the machine diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 162ed5c6..5f369e11 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,8 +12,12 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, + joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js"; @@ -23,10 +26,6 @@ import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; function firstNonEmptyLine(text: string): string { return ( @@ -82,16 +81,9 @@ function cursorSkillsHome(): string { return path.join(os.homedir(), ".cursor", "skills"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - type EnsureCursorSkillsInjectedOptions = { skillsDir?: string | null; + skillsEntries?: Array<{ name: string; source: string }>; skillsHome?: string; linkSkill?: (source: string, target: string) => Promise; }; @@ -100,8 +92,13 @@ export async function ensureCursorSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCursorSkillsInjectedOptions = {}, ) { - const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = options.skillsEntries + ?? (options.skillsDir + ? (await fs.readdir(options.skillsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) })) + : await listPaperclipSkillEntries(__moduleDir)); + if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? cursorSkillsHome(); try { @@ -113,31 +110,26 @@ export async function ensureCursorSkillsInjected( ); return; } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { await onLog( "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`, ); - return; } - const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target)); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - try { - await linkSkill(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( @@ -165,6 +157,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -238,6 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } @@ -277,6 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); - const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`; + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + paperclipEnvNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["-p", "--output-format", "stream-json", "--workspace", cwd]; @@ -349,6 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, ts: string): T kind: "tool_call", ts, name: toolName, + toolUseId: callId, input, }]; } diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 2f355cfe..8fea361a 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -1,8 +1,9 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/gemini-local/package.json similarity index 91% rename from packages/adapters/openclaw/package.json rename to packages/adapters/gemini-local/package.json index c8bd561d..1d482fb1 100644 --- a/packages/adapters/openclaw/package.json +++ b/packages/adapters/gemini-local/package.json @@ -1,6 +1,6 @@ { - "name": "@paperclipai/adapter-openclaw", - "version": "0.2.7", + "name": "@paperclipai/adapter-gemini-local", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", @@ -32,7 +32,8 @@ "types": "./dist/index.d.ts" }, "files": [ - "dist" + "dist", + "skills" ], "scripts": { "build": "tsc", diff --git a/packages/adapters/gemini-local/src/cli/format-event.ts b/packages/adapters/gemini-local/src/cli/format-event.ts new file mode 100644 index 00000000..48611f02 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/format-event.ts @@ -0,0 +1,208 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + return; + } + + const message = asRecord(messageRaw); + if (!message) return; + + const directText = asString(message.text).trim(); + if (directText) console.log(colorize(`${prefix}: ${directText}`)); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + console.log(pc.yellow(`tool_call: ${name}`)); + const input = part.input ?? part.arguments ?? part.args; + if (input !== undefined) console.log(pc.gray(stringifyUnknown(input))); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (contentText) console.log((isError ? pc.red : pc.gray)(contentText)); + } + } +} + +function printUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))); + const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))); + const cached = asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ); + const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); +} + +export function printGeminiStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id); + const model = asString(parsed.model); + const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""] + .filter(Boolean) + .join(", "); + console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`)); + return; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + console.log(pc.blue(`system: ${subtype || "event"}`)); + return; + } + + if (type === "assistant") { + printTextMessage("assistant", pc.green, parsed.message); + return; + } + + if (type === "user") { + printTextMessage("user", pc.gray, parsed.message); + return; + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_call") { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + const [toolName] = toolCall ? Object.keys(toolCall) : []; + if (!toolCall || !toolName) { + console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`)); + return; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + if (subtype === "started" || subtype === "start") { + console.log(pc.yellow(`tool_call: ${toolName}`)); + console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload))); + return; + } + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error))); + return; + } + console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`)); + return; + } + + if (type === "result") { + printUsage(parsed); + const subtype = asString(parsed.subtype, "result"); + const isError = parsed.is_error === true; + if (subtype || isError) { + console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`)); + } + return; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/gemini-local/src/cli/index.ts b/packages/adapters/gemini-local/src/cli/index.ts new file mode 100644 index 00000000..49ec4426 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGeminiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts new file mode 100644 index 00000000..64b7b99f --- /dev/null +++ b/packages/adapters/gemini-local/src/index.ts @@ -0,0 +1,47 @@ +export const type = "gemini_local"; +export const label = "Gemini CLI (local)"; +export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; + +export const models = [ + { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, +]; + +export const agentConfigurationDoc = `# gemini_local agent configuration + +Adapter: gemini_local + +Use when: +- You want Paperclip to run the Gemini CLI locally on the host machine +- You want Gemini chat sessions resumed across heartbeats with --resume +- You want Paperclip skills injected locally without polluting the global environment + +Don't use when: +- You need webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Gemini CLI is not installed on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt +- promptTemplate (string, optional): run prompt template +- model (string, optional): Gemini model id. Defaults to auto. +- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none) +- command (string, optional): defaults to "gemini" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use positional prompt arguments, not stdin. +- Sessions resume with --resume when stored session cwd matches the current cwd. +- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location. +- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login. +`; diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts new file mode 100644 index 00000000..e2769c3e --- /dev/null +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -0,0 +1,452 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePaperclipSkillSymlink, + joinPromptSections, + ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, + parseObject, + redactEnvForLogs, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, + isGeminiUnknownSessionError, + parseGeminiJsonl, +} from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveGeminiBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") + ? "api" + : "subscription"; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use run_shell_command with curl to make Paperclip API requests.", + "GET example:", + ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`, + "POST/PATCH example:", + ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`, + "", + "", + ].join("\n"); +} + +function geminiSkillsHome(): string { + return path.join(os.homedir(), ".gemini", "skills"); +} + +/** + * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks. + * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds + * both its auth credentials and the injected skills in the real home directory. + */ +async function ensureGeminiSkillsInjected( + onLog: AdapterExecutionContext["onLog"], +): Promise { + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; + + const skillsHome = geminiSkillsHome(); + try { + await fs.mkdir(skillsHome, { recursive: true }); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`, + ); + } + + for (const entry of skillsEntries) { + const target = path.join(skillsHome, entry.name); + + try { + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; + await onLog( + "stderr", + `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "gemini"); + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const sandbox = asBoolean(config.sandbox, false); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureGeminiSkillsInjected(onLog); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (agentHome) env.AGENT_HOME = agentHome; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const billingType = resolveGeminiBillingType(env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; + notes.push("Added --approval-mode yolo for unattended execution."); + if (!instructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); + + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedPrompt = renderTemplate(promptTemplate, templateData); + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--output-format", "stream-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + args.push("--approval-mode", "yolo"); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push(prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "gemini_local", + command, + cwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: redactEnvForLogs(env), + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + parsed: parseGeminiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + const authMeta = detectGeminiAuthRequired({ + parsed: attempt.parsed.resultEvent, + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }); + + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null, + clearSession: clearSessionOnMissingSession, + }; + } + + const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode); + + // On retry, don't fall back to old session ID — the old session was stale + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const structuredFailure = attempt.parsed.resultEvent + ? describeGeminiFailure(attempt.parsed.resultEvent) + : null; + const fallbackErrorMessage = + parsedError || + structuredFailure || + stderrLine || + `Gemini exited with code ${attempt.proc.exitCode ?? -1}`; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage, + errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null, + usage: attempt.parsed.usage, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "google", + model, + billingType, + costUsd: attempt.parsed.costUsd, + resultJson: attempt.parsed.resultEvent ?? { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + question: attempt.parsed.question, + clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stderr", + `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts new file mode 100644 index 00000000..1d35a2bf --- /dev/null +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -0,0 +1,70 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + parseGeminiJsonl, + isGeminiUnknownSessionError, + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, +} from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts new file mode 100644 index 00000000..4fe98fb6 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -0,0 +1,263 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function collectMessageText(message: unknown): string[] { + if (typeof message === "string") { + const trimmed = message.trim(); + return trimmed ? [trimmed] : []; + } + + const record = parseObject(message); + const direct = asString(record.text, "").trim(); + const lines: string[] = direct ? [direct] : []; + const content = Array.isArray(record.content) ? record.content : []; + + for (const partRaw of content) { + const part = parseObject(partRaw); + const type = asString(part.type, "").trim(); + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text, "").trim() || asString(part.content, "").trim(); + if (text) lines.push(text); + } + } + + return lines; +} + +function readSessionId(event: Record): string | null { + return ( + asString(event.session_id, "").trim() || + asString(event.sessionId, "").trim() || + asString(event.sessionID, "").trim() || + asString(event.checkpoint_id, "").trim() || + asString(event.thread_id, "").trim() || + null + ); +} + +function asErrorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "") || + asString(rec.error, "") || + asString(rec.code, "") || + asString(rec.detail, ""); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function accumulateUsage( + target: { inputTokens: number; cachedInputTokens: number; outputTokens: number }, + usageRaw: unknown, +) { + const usage = parseObject(usageRaw); + const usageMetadata = parseObject(usage.usageMetadata); + const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage; + + target.inputTokens += asNumber( + source.input_tokens, + asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)), + ); + target.cachedInputTokens += asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)), + ); + target.outputTokens += asNumber( + source.output_tokens, + asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)), + ); +} + +export function parseGeminiJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + let errorMessage: string | null = null; + let costUsd: number | null = null; + let resultEvent: Record | null = null; + let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const foundSessionId = readSessionId(event); + if (foundSessionId) sessionId = foundSessionId; + + const type = asString(event.type, "").trim(); + + if (type === "assistant") { + messages.push(...collectMessageText(event.message)); + const messageObj = parseObject(event.message); + const content = Array.isArray(messageObj.content) ? messageObj.content : []; + for (const partRaw of content) { + const part = parseObject(partRaw); + if (asString(part.type, "").trim() === "question") { + question = { + prompt: asString(part.prompt, "").trim(), + choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => { + const choice = parseObject(choiceRaw); + return { + key: asString(choice.key, "").trim(), + label: asString(choice.label, "").trim(), + description: asString(choice.description, "").trim() || undefined, + }; + }), + }; + break; // only one question per message + } + } + continue; + } + + if (type === "result") { + resultEvent = event; + accumulateUsage(usage, event.usage ?? event.usageMetadata); + const resultText = + asString(event.result, "").trim() || + asString(event.text, "").trim() || + asString(event.response, "").trim(); + if (resultText && messages.length === 0) messages.push(resultText); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error"; + if (isError) { + const text = asErrorText(event.error ?? event.message ?? event.result).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + continue; + } + + if (type === "system") { + const subtype = asString(event.subtype, "").trim().toLowerCase(); + if (subtype === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish" || event.usage || event.usageMetadata) { + accumulateUsage(usage, event.usage ?? event.usageMetadata); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + costUsd, + errorMessage, + resultEvent, + question, + }; +} + +export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test( + haystack, + ); +} + +function extractGeminiErrorMessages(parsed: Record): string[] { + const messages: string[] = []; + const errorMsg = asString(parsed.error, "").trim(); + if (errorMsg) messages.push(errorMsg); + + const raw = Array.isArray(parsed.errors) ? parsed.errors : []; + for (const entry of raw) { + if (typeof entry === "string") { + const msg = entry.trim(); + if (msg) messages.push(msg); + continue; + } + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const obj = entry as Record; + const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, ""); + if (msg) { + messages.push(msg); + continue; + } + try { + messages.push(JSON.stringify(obj)); + } catch { + // skip non-serializable entry + } + } + + return messages; +} + +export function describeGeminiFailure(parsed: Record): string | null { + const status = asString(parsed.status, ""); + const errors = extractGeminiErrorMessages(parsed); + + const detail = errors[0] ?? ""; + const parts = ["Gemini run failed"]; + if (status) parts.push(`status=${status}`); + if (detail) parts.push(detail); + return parts.length > 1 ? parts.join(": ") : null; +} + +const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; + +export function detectGeminiAuthRequired(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { requiresAuth: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line)); + return { requiresAuth }; +} + +export function isGeminiTurnLimitResult( + parsed: Record | null | undefined, + exitCode?: number | null, +): boolean { + if (exitCode === 53) return true; + if (!parsed) return false; + + const status = asString(parsed.status, "").trim().toLowerCase(); + if (status === "turn_limit" || status === "max_turns") return true; + + const error = asString(parsed.error, "").trim(); + return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error); +} diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts new file mode 100644 index 00000000..8f63e5e2 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -0,0 +1,223 @@ +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asBoolean, + asString, + asStringArray, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + parseObject, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "gemini"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "gemini_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "gemini_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "gemini_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "gemini_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const configGeminiApiKey = env.GEMINI_API_KEY; + const hostGeminiApiKey = process.env.GEMINI_API_KEY; + const configGoogleApiKey = env.GOOGLE_API_KEY; + const hostGoogleApiKey = process.env.GOOGLE_API_KEY; + const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; + if ( + isNonEmpty(configGeminiApiKey) || + isNonEmpty(hostGeminiApiKey) || + isNonEmpty(configGoogleApiKey) || + isNonEmpty(hostGoogleApiKey) || + hasGca + ) { + const source = hasGca + ? "Google account login (GCA)" + : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) + ? "adapter config env" + : "server environment"; + checks.push({ + code: "gemini_api_key_present", + level: "info", + message: "Gemini API credentials are set for CLI authentication.", + detail: `Detected in ${source}.`, + }); + } else { + checks.push({ + code: "gemini_api_key_missing", + level: "info", + message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).", + hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.", + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "gemini")) { + checks.push({ + code: "gemini_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `gemini`.", + detail: command, + hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.", + }); + } else { + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); + const sandbox = asBoolean(config.sandbox, false); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["--output-format", "stream-json"]; + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + if (approvalMode !== "default") args.push("--approval-mode", approvalMode); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("Respond with hello."); + + const probe = await runChildProcess( + `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + onLog: async () => { }, + }, + ); + const parsed = parseGeminiJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authMeta = detectGeminiAuthRequired({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); + + if (probe.timedOut) { + checks.push({ + code: "gemini_hello_probe_timed_out", + level: "warn", + message: "Gemini hello probe timed out.", + hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Gemini hello probe succeeded." + : "Gemini probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.", + }), + }); + } else if (authMeta.requiresAuth) { + checks.push({ + code: "gemini_hello_probe_auth_required", + level: "warn", + message: "Gemini CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.", + }); + } else { + checks.push({ + code: "gemini_hello_probe_failed", + level: "error", + message: "Gemini hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.", + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/gemini-local/src/server/utils.ts b/packages/adapters/gemini-local/src/server/utils.ts new file mode 100644 index 00000000..fb11c75d --- /dev/null +++ b/packages/adapters/gemini-local/src/server/utils.ts @@ -0,0 +1,8 @@ +export function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts new file mode 100644 index 00000000..5ce333a4 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -0,0 +1,76 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGeminiLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; + ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 15; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + ac.sandbox = !v.dangerouslyBypassSandbox; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/gemini-local/src/ui/index.ts b/packages/adapters/gemini-local/src/ui/index.ts new file mode 100644 index 00000000..5d7708b1 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGeminiStdoutLine } from "./parse-stdout.js"; +export { buildGeminiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..47426fa3 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/parse-stdout.ts @@ -0,0 +1,274 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind, ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind, ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type !== "output_text" && type !== "text" && type !== "content") continue; + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind, ts, text }); + } + + return entries; +} + +function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind: "assistant", ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind: "assistant", ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind: "assistant", ts, text }); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) entries.push({ kind: "thinking", ts, text }); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + entries.push({ + kind: "tool_call", + ts, + name, + input: part.input ?? part.arguments ?? part.args ?? {}, + }); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const toolUseId = + asString(part.tool_use_id) || + asString(part.toolUseId) || + asString(part.call_id) || + asString(part.id) || + "tool_result"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + entries.push({ + kind: "tool_result", + ts, + toolUseId, + content: contentText, + isError, + }); + } + } + + return entries; +} + +function parseTopLevelToolEvent(parsed: Record, ts: string): TranscriptEntry[] { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call"))); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + if (!toolCall) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + + const [toolName] = Object.keys(toolCall); + if (!toolName) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + + if (subtype === "started" || subtype === "start") { + return [{ + kind: "tool_call", + ts, + name: toolName, + input: payload.args ?? payload.input ?? payload.arguments ?? payload, + }]; + } + + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const result = payload.result ?? payload.output ?? payload.error; + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + return [{ + kind: "tool_result", + ts, + toolUseId: callId, + content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`, + isError, + }]; + } + + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }]; +} + +function readSessionId(parsed: Record): string { + return ( + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id) || + asString(parsed.thread_id) + ); +} + +function readUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + return { + inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))), + outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))), + cachedTokens: asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ), + }; +} + +export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = readSessionId(parsed); + return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }]; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }]; + } + + if (type === "assistant") { + return parseAssistantMessage(parsed.message, ts); + } + + if (type === "user") { + return collectTextEntries(parsed.message, ts, "user"); + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + return text ? [{ kind: "thinking", ts, text }] : []; + } + + if (type === "tool_call") { + return parseTopLevelToolEvent(parsed, ts); + } + + if (type === "result") { + const usage = readUsage(parsed); + const errors = parsed.is_error === true + ? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean) + : []; + return [{ + kind: "result", + ts, + text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedTokens: usage.cachedTokens, + costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))), + subtype: asString(parsed.subtype, "result"), + isError: parsed.is_error === true, + errors, + }]; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/gemini-local/tsconfig.json similarity index 100% rename from packages/adapters/openclaw/tsconfig.json rename to packages/adapters/gemini-local/tsconfig.json diff --git a/packages/adapters/openclaw-gateway/CHANGELOG.md b/packages/adapters/openclaw-gateway/CHANGELOG.md new file mode 100644 index 00000000..f78f5181 --- /dev/null +++ b/packages/adapters/openclaw-gateway/CHANGELOG.md @@ -0,0 +1,20 @@ +# @paperclipai/adapter-openclaw-gateway + +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md new file mode 100644 index 00000000..ba3edde2 --- /dev/null +++ b/packages/adapters/openclaw-gateway/README.md @@ -0,0 +1,72 @@ +# OpenClaw Gateway Adapter + +This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol. + +## Transport + +This adapter always uses WebSocket gateway transport. + +- URL must be `ws://` or `wss://` +- Connect flow follows gateway protocol: +1. receive `connect.challenge` +2. send `req connect` (protocol/client/auth/device payload) +3. send `req agent` +4. wait for completion via `req agent.wait` +5. stream `event agent` frames into Paperclip logs/transcript parsing + +## Auth Modes + +Gateway credentials can be provided in any of these ways: + +- `authToken` / `token` in adapter config +- `headers.x-openclaw-token` +- `headers.x-openclaw-auth` (legacy) +- `password` (shared password mode) + +When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer `. + +## Device Auth + +By default the adapter sends a signed `device` payload in `connect` params. + +- set `disableDeviceAuth=true` to omit device signing +- set `devicePrivateKeyPem` to pin a stable signing key +- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. + +## Session Strategy + +The adapter supports the same session routing model as HTTP OpenClaw mode: + +- `sessionKeyStrategy=issue|fixed|run` +- `sessionKey` is used when strategy is `fixed` + +Resolved session key is sent as `agent.sessionKey`. + +## Payload Mapping + +The agent request is built as: + +- required fields: + - `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix) + - `idempotencyKey` (Paperclip `runId`) + - `sessionKey` (resolved strategy) +- optional additions: + - all `payloadTemplate` fields merged in + - `agentId` from config if set and not already in template + +## Timeouts + +- `timeoutSec` controls adapter-level request budget +- `waitTimeoutMs` controls `agent.wait.timeoutMs` + +If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`. + +## Log Format + +Structured gateway event logs use: + +- `[openclaw-gateway] ...` for lifecycle/system logs +- `[openclaw-gateway:event] run= stream= data=` for `event agent` frames + +UI/CLI parsers consume these lines to render transcript updates. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md new file mode 100644 index 00000000..66ff2a4a --- /dev/null +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -0,0 +1,109 @@ +# OpenClaw Gateway Onboarding and Test Plan + +## Scope +This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only. + +- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching) +- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`) + +## Requirements +1. OpenClaw test image must be stock/clean every run. +2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed). +3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`. +4. Invite/access flow must be secure: +- invite prompt endpoint is board-permission protected +- CEO agent is allowed to invoke the invite prompt endpoint for their own company +5. E2E pass criteria must include the 3 functional task cases. + +## Current Product Flow +1. Board/CEO opens company settings. +2. Click `Generate OpenClaw Invite Prompt`. +3. Paste generated prompt into OpenClaw chat. +4. OpenClaw submits invite acceptance with: +- `adapterType: "openclaw_gateway"` +- `agentDefaultsPayload.url: ws://... | wss://...` +- `agentDefaultsPayload.headers["x-openclaw-token"]` +5. Board approves join request. +6. OpenClaw claims API key and installs/uses Paperclip skill. +7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key. + +## Technical Contract (Gateway) +`agentDefaultsPayload` minimum: +```json +{ + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" } +} +``` + +Recommended fields: +```json +{ + "paperclipApiUrl": "http://host.docker.internal:3100", + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] +} +``` + +Security/pairing defaults: +- `disableDeviceAuth`: default false +- `devicePrivateKeyPem`: generated during join if missing + +## Codex Automation Workflow + +### 0) Reset and boot +```bash +OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker +if [ -d "$OPENCLAW_DOCKER_DIR" ]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true +fi + +docker image rm openclaw:local || true +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` + +### 1) Start Paperclip +```bash +pnpm dev --tailscale-auth +curl -fsS http://127.0.0.1:3100/api/health +``` + +### 2) Invite + join + approval +- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt` +- paste prompt to OpenClaw +- approve join request +- assert created agent: + - `adapterType == openclaw_gateway` + - token header exists and length >= 16 + - `devicePrivateKeyPem` exists + +### 3) Pairing stabilization +- if first run returns `pairing required`, approve pending device in OpenClaw +- rerun task and confirm success +- assert later runs do not require re-pairing for same agent + +### 4) Functional E2E assertions +1. Task assigned to OpenClaw is completed and closed. +2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat). +3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task. + +## Manual Smoke Checklist +Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook. + +## Regression Gates +Required before merge: +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +If full suite is too heavy locally, run at least: +```bash +pnpm --filter @paperclipai/server test:run -- openclaw-gateway +pnpm --filter @paperclipai/server typecheck +pnpm --filter @paperclipai/ui typecheck +pnpm --filter paperclipai typecheck +``` diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json new file mode 100644 index 00000000..323d09a2 --- /dev/null +++ b/packages/adapters/openclaw-gateway/package.json @@ -0,0 +1,52 @@ +{ + "name": "@paperclipai/adapter-openclaw-gateway", + "version": "0.3.1", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/ws": "^8.18.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/openclaw-gateway/src/cli/format-event.ts b/packages/adapters/openclaw-gateway/src/cli/format-event.ts new file mode 100644 index 00000000..55814317 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/format-event.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + if (line.startsWith("[openclaw-gateway:event]")) { + console.log(pc.cyan(line)); + return; + } + + if (line.startsWith("[openclaw-gateway]")) { + console.log(pc.blue(line)); + return; + } + + console.log(pc.gray(line)); +} diff --git a/packages/adapters/openclaw-gateway/src/cli/index.ts b/packages/adapters/openclaw-gateway/src/cli/index.ts new file mode 100644 index 00000000..9c621bcb --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenClawGatewayStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts new file mode 100644 index 00000000..195edfbf --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -0,0 +1,54 @@ +export const type = "openclaw_gateway"; +export const label = "OpenClaw Gateway"; + +export const models: { id: string; label: string }[] = []; + +export const agentConfigurationDoc = `# openclaw_gateway agent configuration + +Adapter: openclaw_gateway + +Use when: +- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol. +- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. + +Don't use when: +- You only expose OpenClaw HTTP endpoints. +- Your deployment does not permit outbound WebSocket access from the Paperclip server. + +Core fields: +- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://) +- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth +- authToken (string, optional): shared gateway token override +- password (string, optional): gateway shared password, if configured + +Gateway connect identity fields: +- clientId (string, optional): gateway client id (default gateway-client) +- clientMode (string, optional): gateway client mode (default backend) +- clientVersion (string, optional): client version string +- role (string, optional): gateway role (default operator) +- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"]) +- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false) + +Request behavior fields: +- payloadTemplate (object, optional): additional fields merged into gateway agent params +- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments +- timeoutSec (number, optional): adapter timeout in seconds (default 120) +- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) +- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text + +Session routing fields: +- sessionKeyStrategy (string, optional): issue (default), fixed, or run +- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) + +Standard outbound payload additions: +- paperclip (object): standardized Paperclip context added to every gateway agent request +- paperclip.workspace (object, optional): resolved execution workspace for this run +- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run +- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace + +Standard result metadata supported: +- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports +- meta.previewUrl (string, optional): shorthand single preview URL +- meta.previewUrls (string[], optional): shorthand multiple preview URLs +`; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts new file mode 100644 index 00000000..eaacbd33 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -0,0 +1,1433 @@ +import type { + AdapterExecutionContext, + AdapterExecutionResult, + AdapterRuntimeServiceReport, +} from "@paperclipai/adapter-utils"; +import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import crypto, { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +type SessionKeyStrategy = "fixed" | "issue" | "run"; + +type WakePayload = { + runId: string; + agentId: string; + companyId: string; + taskId: string | null; + issueId: string | null; + wakeReason: string | null; + wakeCommentId: string | null; + approvalId: string | null; + approvalStatus: string | null; + issueIds: string[]; +}; + +type GatewayDeviceIdentity = { + deviceId: string; + publicKeyRawBase64Url: string; + privateKeyPem: string; + source: "configured" | "ephemeral"; +}; + +type GatewayRequestFrame = { + type: "req"; + id: string; + method: string; + params?: unknown; +}; + +type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + }; +}; + +type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + expectFinal: boolean; + timer: ReturnType | null; +}; + +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + +type GatewayClientOptions = { + url: string; + headers: Record; + onEvent: (frame: GatewayEventFrame) => Promise | void; + onLog: AdapterExecutionContext["onLog"]; +}; + +type GatewayClientRequestOptions = { + timeoutMs: number; + expectFinal?: boolean; +}; + +const PROTOCOL_VERSION = 3; +const DEFAULT_SCOPES = ["operator.admin"]; +const DEFAULT_CLIENT_ID = "gateway-client"; +const DEFAULT_CLIENT_MODE = "backend"; +const DEFAULT_CLIENT_VERSION = "paperclip"; +const DEFAULT_ROLE = "operator"; + +const SENSITIVE_LOG_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(1, Math.floor(value)); + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed)); + } + return null; +} + +function parseBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return fallback; +} + +function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { + const normalized = asString(value, "issue").trim().toLowerCase(); + if (normalized === "fixed" || normalized === "run") return normalized; + return "issue"; +} + +function resolveSessionKey(input: { + strategy: SessionKeyStrategy; + configuredSessionKey: string | null; + runId: string; + issueId: string | null; +}): string { + const fallback = input.configuredSessionKey ?? "paperclip"; + if (input.strategy === "run") return `paperclip:run:${input.runId}`; + if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; + return fallback; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeScopes(value: unknown): string[] { + const parsed = toStringArray(value); + return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; +} + +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function headerMapHasIgnoreCase(headers: Record, key: string): boolean { + return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); +} + +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function isSensitiveLogKey(key: string): boolean { + return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); +} + +function sha256Prefix(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function redactSecretForLog(value: string): string { + return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; +} + +function truncateForLog(value: string, maxChars = 320): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; +} + +function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { + const currentKey = keyPath[keyPath.length - 1] ?? ""; + if (typeof value === "string") { + if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); + return truncateForLog(value); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return value; + } + if (Array.isArray(value)) { + if (depth >= 6) return "[array-truncated]"; + const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); + if (value.length > 20) out.push(`[+${value.length - 20} more items]`); + return out; + } + if (typeof value === "object") { + if (depth >= 6) return "[object-truncated]"; + const entries = Object.entries(value as Record); + const out: Record = {}; + for (const [key, entry] of entries.slice(0, 80)) { + out[key] = redactForLog(entry, [...keyPath, key], depth + 1); + } + if (entries.length > 80) { + out.__truncated__ = `+${entries.length - 80} keys`; + } + return out; + } + return String(value); +} + +function stringifyForLog(value: unknown, maxChars: number): string { + const text = JSON.stringify(value); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { + const { runId, agent, context } = ctx; + return { + runId, + agentId: agent.id, + companyId: agent.companyId, + taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), + issueId: nonEmpty(context.issueId), + wakeReason: nonEmpty(context.wakeReason), + wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), + approvalId: nonEmpty(context.approvalId), + approvalStatus: nonEmpty(context.approvalStatus), + issueIds: Array.isArray(context.issueIds) + ? context.issueIds.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : [], + }; +} + +function resolvePaperclipApiUrlOverride(value: unknown): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString(); + } catch { + return null; + } +} + +function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { + const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); + const paperclipEnv: Record = { + ...buildPaperclipEnv(ctx.agent), + PAPERCLIP_RUN_ID: ctx.runId, + }; + + if (paperclipApiUrlOverride) { + paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; + } + if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; + if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; + if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; + if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; + if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; + if (wakePayload.issueIds.length > 0) { + paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); + } + + return paperclipEnv; +} + +function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { + const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; + const orderedKeys = [ + "PAPERCLIP_RUN_ID", + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_API_URL", + "PAPERCLIP_TASK_ID", + "PAPERCLIP_WAKE_REASON", + "PAPERCLIP_WAKE_COMMENT_ID", + "PAPERCLIP_APPROVAL_ID", + "PAPERCLIP_APPROVAL_STATUS", + "PAPERCLIP_LINKED_ISSUE_IDS", + ]; + + const envLines: string[] = []; + for (const key of orderedKeys) { + const value = paperclipEnv[key]; + if (!value) continue; + envLines.push(`${key}=${value}`); + } + + const issueIdHint = payload.taskId ?? payload.issueId ?? ""; + const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; + + const lines = [ + "Paperclip wake event for a cloud adapter.", + "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", + "Set these values in your run context:", + ...envLines, + `PAPERCLIP_API_KEY=`, + "", + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, + "", + `api_base=${apiBaseHint}`, + `task_id=${payload.taskId ?? ""}`, + `issue_id=${payload.issueId ?? ""}`, + `wake_reason=${payload.wakeReason ?? ""}`, + `wake_comment_id=${payload.wakeCommentId ?? ""}`, + `approval_id=${payload.approvalId ?? ""}`, + `approval_status=${payload.approvalStatus ?? ""}`, + `linked_issue_ids=${payload.issueIds.join(",")}`, + "", + "HTTP rules:", + "- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.", + "- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.", + "- Use only /api endpoints listed below.", + "- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.", + "", + "Workflow:", + "1) GET /api/agents/me", + `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, + "3) If issueId exists:", + " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}", + " - GET /api/issues/{issueId}", + " - GET /api/issues/{issueId}/comments", + " - Execute the issue instructions exactly.", + " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", + " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", + "4) If issueId does not exist:", + " - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked", + " - Pick in_progress first, then todo, then blocked, then execute step 3.", + "", + "Useful endpoints for issue work:", + "- POST /api/issues/{issueId}/comments", + "- PATCH /api/issues/{issueId}", + "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", + "", + "Complete the workflow in this run.", + ]; + return lines.join("\n"); +} + +function appendWakeText(baseText: string, wakeText: string): string { + const trimmedBase = baseText.trim(); + return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; +} + +function buildStandardPaperclipPayload( + ctx: AdapterExecutionContext, + wakePayload: WakePayload, + paperclipEnv: Record, + payloadTemplate: Record, +): Record { + const templatePaperclip = parseObject(payloadTemplate.paperclip); + const workspace = asRecord(ctx.context.paperclipWorkspace); + const workspaces = Array.isArray(ctx.context.paperclipWorkspaces) + ? ctx.context.paperclipWorkspaces.filter((entry): entry is Record => Boolean(asRecord(entry))) + : []; + const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime); + const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents) + ? ctx.context.paperclipRuntimeServiceIntents.filter( + (entry): entry is Record => Boolean(asRecord(entry)), + ) + : []; + + const standardPaperclip: Record = { + runId: ctx.runId, + companyId: ctx.agent.companyId, + agentId: ctx.agent.id, + agentName: ctx.agent.name, + taskId: wakePayload.taskId, + issueId: wakePayload.issueId, + issueIds: wakePayload.issueIds, + wakeReason: wakePayload.wakeReason, + wakeCommentId: wakePayload.wakeCommentId, + approvalId: wakePayload.approvalId, + approvalStatus: wakePayload.approvalStatus, + apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null, + }; + + if (workspace) { + standardPaperclip.workspace = workspace; + } + if (workspaces.length > 0) { + standardPaperclip.workspaces = workspaces; + } + if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) { + standardPaperclip.workspaceRuntime = { + ...configuredWorkspaceRuntime, + ...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}), + }; + } + + return { + ...templatePaperclip, + ...standardPaperclip, + }; +} + +function normalizeUrl(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { + const key = crypto.createPublicKey(publicKeyPem); + const spki = key.export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function signDevicePayload(privateKeyPem: string, payload: string): string { + const key = crypto.createPrivateKey(privateKeyPem); + const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); + return base64UrlEncode(sig); +} + +function buildDeviceAuthPayloadV3(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce: string; + platform?: string | null; + deviceFamily?: string | null; +}): string { + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const platform = params.platform?.trim() ?? ""; + const deviceFamily = params.deviceFamily?.trim() ?? ""; + return [ + "v3", + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + params.nonce, + platform, + deviceFamily, + ].join("|"); +} + +function resolveDeviceIdentity(config: Record): GatewayDeviceIdentity { + const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem); + if (configuredPrivateKey) { + const privateKey = crypto.createPrivateKey(configuredPrivateKey); + const publicKey = crypto.createPublicKey(privateKey); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem: configuredPrivateKey, + source: "configured", + }; + } + + const generated = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem, + source: "ephemeral", + }; +} + +function isResponseFrame(value: unknown): value is GatewayResponseFrame { + const record = asRecord(value); + return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean"); +} + +function isEventFrame(value: unknown): value is GatewayEventFrame { + const record = asRecord(value); + return Boolean(record && record.type === "event" && typeof record.event === "string"); +} + +class GatewayWsClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private challengePromise: Promise; + private resolveChallenge!: (nonce: string) => void; + private rejectChallenge!: (err: Error) => void; + + constructor(private readonly opts: GatewayClientOptions) { + this.challengePromise = new Promise((resolve, reject) => { + this.resolveChallenge = resolve; + this.rejectChallenge = reject; + }); + } + + async connect( + buildConnectParams: (nonce: string) => Record, + timeoutMs: number, + ): Promise | null> { + this.ws = new WebSocket(this.opts.url, { + headers: this.opts.headers, + maxPayload: 25 * 1024 * 1024, + }); + + const ws = this.ws; + + ws.on("message", (data) => { + this.handleMessage(rawDataToString(data)); + }); + + ws.on("close", (code, reason) => { + const reasonText = rawDataToString(reason); + const err = new Error(`gateway closed (${code}): ${reasonText}`); + this.failPending(err); + this.rejectChallenge(err); + }); + + ws.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`); + }); + + await withTimeout( + new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`)); + }; + const cleanup = () => { + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }), + timeoutMs, + "gateway websocket open timeout", + ); + + const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout"); + const signedConnectParams = buildConnectParams(nonce); + + const hello = await this.request | null>("connect", signedConnectParams, { + timeoutMs, + }); + + return hello; + } + + async request( + method: string, + params: unknown, + opts: GatewayClientRequestOptions, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("gateway not connected"); + } + + const id = randomUUID(); + const frame: GatewayRequestFrame = { + type: "req", + id, + method, + params, + }; + + const payload = JSON.stringify(frame); + const requestPromise = new Promise((resolve, reject) => { + const timer = + opts.timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`gateway request timeout (${method})`)); + }, opts.timeoutMs) + : null; + + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + expectFinal: opts.expectFinal === true, + timer, + }); + }); + + this.ws.send(payload); + return requestPromise; + } + + close() { + if (!this.ws) return; + this.ws.close(1000, "paperclip-complete"); + this.ws = null; + } + + private failPending(err: Error) { + for (const [, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(err); + } + this.pending.clear(); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (isEventFrame(parsed)) { + if (parsed.event === "connect.challenge") { + const payload = asRecord(parsed.payload); + const nonce = nonEmpty(payload?.nonce); + if (nonce) { + this.resolveChallenge(nonce); + return; + } + } + void Promise.resolve(this.opts.onEvent(parsed)).catch(() => { + // Ignore event callback failures and keep stream active. + }); + return; + } + + if (!isResponseFrame(parsed)) return; + + const pending = this.pending.get(parsed.id); + if (!pending) return; + + const payload = asRecord(parsed.payload); + const status = nonEmpty(payload?.status)?.toLowerCase(); + if (pending.expectFinal && status === "accepted") { + return; + } + + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(parsed.id); + + if (parsed.ok) { + pending.resolve(parsed.payload ?? null); + return; + } + + const errorRecord = asRecord(parsed.error); + const message = + nonEmpty(errorRecord?.message) ?? + nonEmpty(errorRecord?.code) ?? + "gateway request failed"; + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); + } +} + +function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = asNumber(record.inputTokens ?? record.input, 0); + const outputTokens = asNumber(record.outputTokens ?? record.output, 0); + const cachedInputTokens = asNumber( + record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read, + 0, + ); + + if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) { + return undefined; + } + + return { + inputTokens, + outputTokens, + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + }; +} + +function extractRuntimeServicesFromMeta(meta: Record | null): AdapterRuntimeServiceReport[] { + if (!meta) return []; + const reports: AdapterRuntimeServiceReport[] = []; + + const runtimeServices = Array.isArray(meta.runtimeServices) + ? meta.runtimeServices.filter((entry): entry is Record => Boolean(asRecord(entry))) + : []; + for (const entry of runtimeServices) { + const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name); + if (!serviceName) continue; + const rawStatus = nonEmpty(entry.status)?.toLowerCase(); + const status = + rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed" + ? rawStatus + : "running"; + const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase(); + const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral"; + const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase(); + const scopeType = + rawScopeType === "project_workspace" || + rawScopeType === "execution_workspace" || + rawScopeType === "agent" + ? rawScopeType + : "run"; + const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase(); + const healthStatus = + rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown" + ? rawHealth + : status === "running" + ? "healthy" + : "unknown"; + + reports.push({ + id: nonEmpty(entry.id), + projectId: nonEmpty(entry.projectId), + projectWorkspaceId: nonEmpty(entry.projectWorkspaceId), + issueId: nonEmpty(entry.issueId), + scopeType, + scopeId: nonEmpty(entry.scopeId), + serviceName, + status, + lifecycle, + reuseKey: nonEmpty(entry.reuseKey), + command: nonEmpty(entry.command), + cwd: nonEmpty(entry.cwd), + port: parseOptionalPositiveInteger(entry.port), + url: nonEmpty(entry.url), + providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId), + ownerAgentId: nonEmpty(entry.ownerAgentId), + stopPolicy: asRecord(entry.stopPolicy), + healthStatus, + }); + } + + const previewUrl = nonEmpty(meta.previewUrl); + if (previewUrl) { + reports.push({ + serviceName: "preview", + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + url: previewUrl, + providerRef: nonEmpty(meta.previewId) ?? previewUrl, + healthStatus: "healthy", + }); + } + + const previewUrls = Array.isArray(meta.previewUrls) + ? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; + previewUrls.forEach((url, index) => { + reports.push({ + serviceName: index === 0 ? "preview" : `preview-${index + 1}`, + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + url, + providerRef: `${url}#${index}`, + healthStatus: "healthy", + }); + }); + + return reports; +} + +function extractResultText(value: unknown): string | null { + const record = asRecord(value); + if (!record) return null; + + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((entry) => { + const payload = asRecord(entry); + return nonEmpty(payload?.text); + }) + .filter((entry): entry is string => Boolean(entry)); + + if (texts.length > 0) return texts.join("\n\n"); + return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const urlValue = asString(ctx.config.url, "").trim(); + if (!urlValue) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "OpenClaw gateway adapter missing url", + errorCode: "openclaw_gateway_url_missing", + }; + } + + const parsedUrl = normalizeUrl(urlValue); + if (!parsedUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Invalid gateway URL: ${urlValue}`, + errorCode: "openclaw_gateway_url_invalid", + }; + } + + if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`, + errorCode: "openclaw_gateway_url_protocol", + }; + } + + const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120))); + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; + const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000; + const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000); + + const payloadTemplate = parseObject(ctx.config.payloadTemplate); + const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport); + + const headers = toStringRecord(ctx.config.headers); + const authToken = resolveAuthToken(parseObject(ctx.config), headers); + const password = nonEmpty(ctx.config.password); + const deviceToken = nonEmpty(ctx.config.deviceToken); + + if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) { + headers.authorization = toAuthorizationHeaderValue(authToken); + } + + const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID; + const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE; + const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION; + const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE; + const scopes = normalizeScopes(ctx.config.scopes); + const deviceFamily = nonEmpty(ctx.config.deviceFamily); + const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false); + + const wakePayload = buildWakePayload(ctx); + const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); + const wakeText = buildWakeText(wakePayload, paperclipEnv); + + const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); + const configuredSessionKey = nonEmpty(ctx.config.sessionKey); + const sessionKey = resolveSessionKey({ + strategy: sessionKeyStrategy, + configuredSessionKey, + runId: ctx.runId, + issueId: wakePayload.issueId, + }); + + const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text); + const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText; + const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate); + + const agentParams: Record = { + ...payloadTemplate, + message, + sessionKey, + idempotencyKey: ctx.runId, + }; + delete agentParams.text; + + const configuredAgentId = nonEmpty(ctx.config.agentId); + if (configuredAgentId && !nonEmpty(agentParams.agentId)) { + agentParams.agentId = configuredAgentId; + } + + if (typeof agentParams.timeout !== "number") { + agentParams.timeout = waitTimeoutMs; + } + + if (ctx.onMeta) { + await ctx.onMeta({ + adapterType: "openclaw_gateway", + command: "gateway", + commandArgs: ["ws", parsedUrl.toString(), "agent"], + context: ctx.context, + }); + } + + const outboundHeaderKeys = Object.keys(headers).sort(); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, + ); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`, + ); + await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); + if (transportHint) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`, + ); + } + if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) { + await ctx.onLog( + "stdout", + "[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n", + ); + } + + const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true); + let autoPairAttempted = false; + let latestResultPayload: unknown = null; + + while (true) { + const trackedRunIds = new Set([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; + await ctx.onLog( + "stdout", + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, + ); + + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; + } + + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } + + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, + }); + + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } + + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); + + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const acceptedResult = asRecord(acceptedPayload?.result); + const latestPayload = asRecord(latestResultPayload); + const latestResult = asRecord(latestPayload?.result); + const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta); + const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta); + const mergedMeta = { + ...(acceptedMeta ?? {}), + ...(latestMeta ?? {}), + }; + const agentMeta = + asRecord(mergedMeta.agentMeta) ?? + asRecord(acceptedMeta?.agentMeta) ?? + asRecord(latestMeta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage); + const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(runtimeServices.length > 0 ? { runtimeServices } : {}), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + const detailedMessage = pairingRequired + ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` + : message; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); + + return { + exitCode: 1, + signal: null, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), + }; + } finally { + client.close(); + } + } +} diff --git a/packages/adapters/openclaw-gateway/src/server/index.ts b/packages/adapters/openclaw-gateway/src/server/index.ts new file mode 100644 index 00000000..04036438 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/index.ts @@ -0,0 +1,2 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; diff --git a/packages/adapters/openclaw-gateway/src/server/test.ts b/packages/adapters/openclaw-gateway/src/server/test.ts new file mode 100644 index 00000000..af4c74d1 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/test.ts @@ -0,0 +1,317 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +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 nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +async function probeGateway(input: { + url: string; + headers: Record; + authToken: string | null; + role: string; + scopes: string[]; + timeoutMs: number; +}): Promise<"ok" | "challenge_only" | "failed"> { + return await new Promise((resolve) => { + const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 }); + const timeout = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + resolve("failed"); + }, input.timeoutMs); + + let completed = false; + + const finish = (status: "ok" | "challenge_only" | "failed") => { + if (completed) return; + completed = true; + clearTimeout(timeout); + try { + ws.close(); + } catch { + // ignore + } + resolve(status); + }; + + ws.on("message", (raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(raw)); + } catch { + return; + } + const event = asRecord(parsed); + if (event?.type === "event" && event.event === "connect.challenge") { + const nonce = nonEmpty(asRecord(event.payload)?.nonce); + if (!nonce) { + finish("failed"); + return; + } + + const connectId = randomUUID(); + ws.send( + JSON.stringify({ + type: "req", + id: connectId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "gateway-client", + version: "paperclip-probe", + platform: process.platform, + mode: "probe", + }, + role: input.role, + scopes: input.scopes, + ...(input.authToken + ? { + auth: { + token: input.authToken, + }, + } + : {}), + }, + }), + ); + return; + } + + if (event?.type === "res") { + if (event.ok === true) { + finish("ok"); + } else { + finish("challenge_only"); + } + } + }); + + ws.on("error", () => { + finish("failed"); + }); + + ws.on("close", () => { + if (!completed) finish("failed"); + }); + }); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const urlValue = asString(config.url, "").trim(); + + if (!urlValue) { + checks.push({ + code: "openclaw_gateway_url_missing", + level: "error", + message: "OpenClaw gateway adapter requires a WebSocket URL.", + hint: "Set adapterConfig.url to ws://host:port (or wss://).", + }); + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + let url: URL | null = null; + try { + url = new URL(urlValue); + } catch { + checks.push({ + code: "openclaw_gateway_url_invalid", + level: "error", + message: `Invalid URL: ${urlValue}`, + }); + } + + if (url && url.protocol !== "ws:" && url.protocol !== "wss:") { + checks.push({ + code: "openclaw_gateway_url_protocol_invalid", + level: "error", + message: `Unsupported URL protocol: ${url.protocol}`, + hint: "Use ws:// or wss://.", + }); + } + + if (url) { + checks.push({ + code: "openclaw_gateway_url_valid", + level: "info", + message: `Configured gateway URL: ${url.toString()}`, + }); + + if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) { + checks.push({ + code: "openclaw_gateway_plaintext_remote_ws", + level: "warn", + message: "Gateway URL uses plaintext ws:// on a non-loopback host.", + hint: "Prefer wss:// for remote gateways.", + }); + } + } + + const headers = toStringRecord(config.headers); + const authToken = resolveAuthToken(config, headers); + const password = nonEmpty(config.password); + const role = nonEmpty(config.role) ?? "operator"; + const scopes = toStringArray(config.scopes); + + if (authToken || password) { + checks.push({ + code: "openclaw_gateway_auth_present", + level: "info", + message: "Gateway credentials are configured.", + }); + } else { + checks.push({ + code: "openclaw_gateway_auth_missing", + level: "warn", + message: "No gateway credentials detected in adapter config.", + hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.", + }); + } + + if (url && (url.protocol === "ws:" || url.protocol === "wss:")) { + try { + const probeResult = await probeGateway({ + url: url.toString(), + headers, + authToken, + role, + scopes: scopes.length > 0 ? scopes : ["operator.admin"], + timeoutMs: 3_000, + }); + + if (probeResult === "ok") { + checks.push({ + code: "openclaw_gateway_probe_ok", + level: "info", + message: "Gateway connect probe succeeded.", + }); + } else if (probeResult === "challenge_only") { + checks.push({ + code: "openclaw_gateway_probe_challenge_only", + level: "warn", + message: "Gateway challenge was received, but connect probe was rejected.", + hint: "Check gateway credentials, scopes, role, and device-auth requirements.", + }); + } else { + checks.push({ + code: "openclaw_gateway_probe_failed", + level: "warn", + message: "Gateway probe failed.", + hint: "Verify network reachability and gateway URL from the Paperclip server host.", + }); + } + } catch (err) { + checks.push({ + code: "openclaw_gateway_probe_error", + level: "warn", + message: err instanceof Error ? err.message : "Gateway probe failed", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/openclaw/src/shared/stream.ts b/packages/adapters/openclaw-gateway/src/shared/stream.ts similarity index 85% rename from packages/adapters/openclaw/src/shared/stream.ts rename to packages/adapters/openclaw-gateway/src/shared/stream.ts index a2e84357..860fc367 100644 --- a/packages/adapters/openclaw/src/shared/stream.ts +++ b/packages/adapters/openclaw-gateway/src/shared/stream.ts @@ -1,4 +1,4 @@ -export function normalizeOpenClawStreamLine(rawLine: string): { +export function normalizeOpenClawGatewayStreamLine(rawLine: string): { stream: "stdout" | "stderr" | null; line: string; } { diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts new file mode 100644 index 00000000..70604f20 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -0,0 +1,30 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.timeoutSec = 120; + ac.waitTimeoutMs = 120000; + ac.sessionKeyStrategy = "issue"; + ac.role = "operator"; + ac.scopes = ["operator.admin"]; + const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? ""); + if (payloadTemplate) ac.payloadTemplate = payloadTemplate; + const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? ""); + if (runtimeServices && Array.isArray(runtimeServices.services)) { + ac.workspaceRuntime = runtimeServices; + } + return ac; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/index.ts b/packages/adapters/openclaw-gateway/src/ui/index.ts new file mode 100644 index 00000000..c2ec0bcf --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js"; +export { buildOpenClawGatewayConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts new file mode 100644 index 00000000..c8cb48ae --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts @@ -0,0 +1,75 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.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): string { + return typeof value === "string" ? value : ""; +} + +function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] { + const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s); + if (!match) return [{ kind: "stdout", ts, text: line }]; + + const stream = asString(match[2]).toLowerCase(); + const data = asRecord(safeJsonParse(asString(match[3]).trim())); + + if (stream === "assistant") { + const delta = asString(data?.delta); + if (delta.length > 0) { + return [{ kind: "assistant", ts, text: delta, delta: true }]; + } + + const text = asString(data?.text); + if (text.length > 0) { + return [{ kind: "assistant", ts, text }]; + } + return []; + } + + if (stream === "error") { + const message = asString(data?.error) || asString(data?.message); + return message ? [{ kind: "stderr", ts, text: message }] : []; + } + + if (stream === "lifecycle") { + const phase = asString(data?.phase).toLowerCase(); + const message = asString(data?.error) || asString(data?.message); + if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) { + return [{ kind: "stderr", ts, text: message }]; + } + } + + return []; +} + +export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { + const normalized = normalizeOpenClawGatewayStreamLine(line); + if (normalized.stream === "stderr") { + return [{ kind: "stderr", ts, text: normalized.line }]; + } + + const trimmed = normalized.line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[openclaw-gateway:event]")) { + return parseAgentEventLine(trimmed, ts); + } + + if (trimmed.startsWith("[openclaw-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: normalized.line }]; +} diff --git a/packages/adapters/openclaw-gateway/tsconfig.json b/packages/adapters/openclaw-gateway/tsconfig.json new file mode 100644 index 00000000..e1b71318 --- /dev/null +++ b/packages/adapters/openclaw-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/openclaw/CHANGELOG.md b/packages/adapters/openclaw/CHANGELOG.md deleted file mode 100644 index 79174ae2..00000000 --- a/packages/adapters/openclaw/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# @paperclipai/adapter-openclaw - -## 0.2.7 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.7 - -## 0.2.6 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.6 - -## 0.2.5 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.5 - -## 0.2.4 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.4 - -## 0.2.3 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.3 - -## 0.2.2 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.2 - -## 0.2.1 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.1 diff --git a/packages/adapters/openclaw/README.md b/packages/adapters/openclaw/README.md deleted file mode 100644 index 10b7d4c1..00000000 --- a/packages/adapters/openclaw/README.md +++ /dev/null @@ -1,139 +0,0 @@ -# OpenClaw Adapter Modes - -This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior. - -## Transport Modes - -The adapter has two transport modes: - -- `sse` (default) -- `webhook` - -Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`). - -## Mode Matrix - -| streamTransport | configured URL path | behavior | -| --- | --- | --- | -| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. | -| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. | -| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. | -| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. | -| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. | -| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. | -| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). | - -## Webhook Payload Shapes - -### 1) Hook Wake (`/hooks/wake`) - -Payload: - -```json -{ - "text": "Paperclip wake event ...", - "mode": "now" -} -``` - -### 2) Hook Agent (`/hooks/agent`) - -Payload: - -```json -{ - "message": "Paperclip wake event ...", - "name": "Optional hook name", - "agentId": "Optional OpenClaw agent id", - "wakeMode": "now", - "deliver": true, - "channel": "last", - "to": "Optional channel recipient", - "model": "Optional model override", - "thinking": "Optional thinking override", - "timeoutSeconds": 120 -} -``` - -Notes: - -- `message` is always used (not `text`) for `/hooks/agent`. -- `sessionKey` is **not** sent by default for `/hooks/agent`. -- To include derived session keys in `/hooks/agent`, set: - - `hookIncludeSessionKey: true` - -### 3) OpenResponses (`/v1/responses`) - -When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape: - -```json -{ - "stream": false, - "model": "openclaw", - "input": "...", - "metadata": { - "paperclip_session_key": "paperclip" - } -} -``` - -## Auth Header Behavior - -You can provide auth either explicitly or via token headers: - -- Explicit auth header: - - `webhookAuthHeader: "Bearer ..."` -- Token headers (adapter derives `Authorization` automatically when missing): - - `headers["x-openclaw-token"]` (preferred) - - `headers["x-openclaw-auth"]` (legacy compatibility) - -## Session Key Behavior - -Session keys are resolved from: - -- `sessionKeyStrategy`: `fixed` (default), `issue`, `run` -- `sessionKey`: used when strategy is `fixed` (default value `paperclip`) - -Where session keys are applied: - -- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata. -- `/hooks/wake`: not sent as a dedicated field. -- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`. -- Generic webhook fallback: sent as `sessionKey` field. - -## Recommended Config Examples - -### SSE (streaming endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/v1/responses", - "streamTransport": "sse", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook (hooks endpoint) - -```json -{ - "url": "http://127.0.0.1:18789/hooks/agent", - "streamTransport": "webhook", - "method": "POST", - "headers": { - "x-openclaw-token": "replace-me" - } -} -``` - -### Webhook with legacy URL retained - -If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will: - -1. try `.../hooks/agent` -2. fallback to original `.../v1/responses` when hook endpoint returns `404` - -This lets older OpenClaw setups continue working while migrating to hooks. diff --git a/packages/adapters/openclaw/src/cli/format-event.ts b/packages/adapters/openclaw/src/cli/format-event.ts deleted file mode 100644 index c0c0c910..00000000 --- a/packages/adapters/openclaw/src/cli/format-event.ts +++ /dev/null @@ -1,18 +0,0 @@ -import pc from "picocolors"; - -export function printOpenClawStreamEvent(raw: string, debug: boolean): void { - const line = raw.trim(); - if (!line) return; - - if (!debug) { - console.log(line); - return; - } - - if (line.startsWith("[openclaw]")) { - console.log(pc.cyan(line)); - return; - } - - console.log(pc.gray(line)); -} diff --git a/packages/adapters/openclaw/src/cli/index.ts b/packages/adapters/openclaw/src/cli/index.ts deleted file mode 100644 index 107ebf8b..00000000 --- a/packages/adapters/openclaw/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printOpenClawStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts deleted file mode 100644 index 2d939d92..00000000 --- a/packages/adapters/openclaw/src/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const type = "openclaw"; -export const label = "OpenClaw"; - -export const models: { id: string; label: string }[] = []; - -export const agentConfigurationDoc = `# openclaw agent configuration - -Adapter: openclaw - -Use when: -- You run an OpenClaw agent remotely and wake it over HTTP. -- You want selectable transport: - - \`sse\` for streaming execution in one Paperclip run. - - \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks). - -Don't use when: -- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). -- The OpenClaw endpoint is not reachable from the Paperclip server. - -Core fields: -- url (string, required): OpenClaw endpoint URL -- streamTransport (string, optional): \`sse\` (default) or \`webhook\` -- method (string, optional): HTTP method, default POST -- headers (object, optional): extra HTTP headers for requests -- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth -- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload -- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\` -- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false) - -Session routing fields: -- sessionKeyStrategy (string, optional): \`fixed\` (default), \`issue\`, or \`run\` -- sessionKey (string, optional): fixed session key value when strategy is \`fixed\` (default \`paperclip\`) - -Operational fields: -- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout) - -Hire-approved callback fields (optional): -- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired -- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST) -- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests -- hireApprovedCallbackHeaders (object): extra headers merged into callback requests -`; diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts deleted file mode 100644 index d2c71583..00000000 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ /dev/null @@ -1,503 +0,0 @@ -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; -import { createHash } from "node:crypto"; -import { parseOpenClawResponse } from "./parse.js"; - -export type OpenClawTransport = "sse" | "webhook"; -export type SessionKeyStrategy = "fixed" | "issue" | "run"; -export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic"; - -export type WakePayload = { - runId: string; - agentId: string; - companyId: string; - taskId: string | null; - issueId: string | null; - wakeReason: string | null; - wakeCommentId: string | null; - approvalId: string | null; - approvalStatus: string | null; - issueIds: string[]; -}; - -export type OpenClawExecutionState = { - method: string; - timeoutSec: number; - headers: Record; - payloadTemplate: Record; - wakePayload: WakePayload; - sessionKey: string; - paperclipEnv: Record; - wakeText: string; -}; - -const SENSITIVE_LOG_KEY_PATTERN = - /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; - -export function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - -export function resolvePaperclipApiUrlOverride(value: unknown): string | null { - const raw = nonEmpty(value); - if (!raw) return null; - try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - return parsed.toString(); - } catch { - return null; - } -} - -export function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "fixed").trim().toLowerCase(); - if (normalized === "issue" || normalized === "run") return normalized; - return "fixed"; -} - -export function resolveSessionKey(input: { - strategy: SessionKeyStrategy; - configuredSessionKey: string | null; - runId: string; - issueId: string | null; -}): string { - const fallback = input.configuredSessionKey ?? "paperclip"; - if (input.strategy === "run") return `paperclip:run:${input.runId}`; - if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; - return fallback; -} - -function normalizeUrlPath(pathname: string): string { - const trimmed = pathname.trim().toLowerCase(); - if (!trimmed) return "/"; - return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed; -} - -function isWakePath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake"); -} - -function isHookAgentPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent"); -} - -function isHookPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return ( - normalized === "/hooks" || - normalized.startsWith("/hooks/") || - normalized.endsWith("/hooks") || - normalized.includes("/hooks/") - ); -} - -export function isHookEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookPath(parsed.pathname); - } catch { - return false; - } -} - -export function isWakeCompatibilityEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isWakePath(parsed.pathname); - } catch { - return false; - } -} - -export function isHookAgentEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookAgentPath(parsed.pathname); - } catch { - return false; - } -} - -export function isOpenResponsesEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - return path === "/v1/responses" || path.endsWith("/v1/responses"); - } catch { - return false; - } -} - -export function resolveEndpointKind(url: string): OpenClawEndpointKind { - if (isOpenResponsesEndpoint(url)) return "open_responses"; - if (isWakeCompatibilityEndpoint(url)) return "hook_wake"; - if (isHookAgentEndpoint(url)) return "hook_agent"; - return "generic"; -} - -export function deriveHookAgentUrlFromResponses(url: string): string | null { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - if (path === "/v1/responses") { - parsed.pathname = "/hooks/agent"; - return parsed.toString(); - } - if (path.endsWith("/v1/responses")) { - parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`; - return parsed.toString(); - } - return null; - } catch { - return null; - } -} - -export function toStringRecord(value: unknown): Record { - const parsed = parseObject(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(parsed)) { - if (typeof entry === "string") { - out[key] = entry; - } - } - return out; -} - -function isSensitiveLogKey(key: string): boolean { - return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); -} - -function sha256Prefix(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 12); -} - -function redactSecretForLog(value: string): string { - return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; -} - -function truncateForLog(value: string, maxChars = 320): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; -} - -export function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { - const currentKey = keyPath[keyPath.length - 1] ?? ""; - if (typeof value === "string") { - if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); - return truncateForLog(value); - } - if (typeof value === "number" || typeof value === "boolean" || value == null) { - return value; - } - if (Array.isArray(value)) { - if (depth >= 6) return "[array-truncated]"; - const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); - if (value.length > 20) out.push(`[+${value.length - 20} more items]`); - return out; - } - if (typeof value === "object") { - if (depth >= 6) return "[object-truncated]"; - const entries = Object.entries(value as Record); - const out: Record = {}; - for (const [key, entry] of entries.slice(0, 80)) { - out[key] = redactForLog(entry, [...keyPath, key], depth + 1); - } - if (entries.length > 80) { - out.__truncated__ = `+${entries.length - 80} keys`; - } - return out; - } - return String(value); -} - -export function stringifyForLog(value: unknown, maxChars: number): string { - const text = JSON.stringify(value); - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; -} - -export function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { - const { runId, agent, context } = ctx; - return { - runId, - agentId: agent.id, - companyId: agent.companyId, - taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), - issueId: nonEmpty(context.issueId), - wakeReason: nonEmpty(context.wakeReason), - wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), - approvalId: nonEmpty(context.approvalId), - approvalStatus: nonEmpty(context.approvalStatus), - issueIds: Array.isArray(context.issueIds) - ? context.issueIds.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ) - : [], - }; -} - -export function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { - const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); - const paperclipEnv: Record = { - ...buildPaperclipEnv(ctx.agent), - PAPERCLIP_RUN_ID: ctx.runId, - }; - - if (paperclipApiUrlOverride) { - paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; - } - if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; - if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; - if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; - if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; - if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; - if (wakePayload.issueIds.length > 0) { - paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); - } - - return paperclipEnv; -} - -export function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { - const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; - const orderedKeys = [ - "PAPERCLIP_RUN_ID", - "PAPERCLIP_AGENT_ID", - "PAPERCLIP_COMPANY_ID", - "PAPERCLIP_API_URL", - "PAPERCLIP_TASK_ID", - "PAPERCLIP_WAKE_REASON", - "PAPERCLIP_WAKE_COMMENT_ID", - "PAPERCLIP_APPROVAL_ID", - "PAPERCLIP_APPROVAL_STATUS", - "PAPERCLIP_LINKED_ISSUE_IDS", - ]; - - const envLines: string[] = []; - for (const key of orderedKeys) { - const value = paperclipEnv[key]; - if (!value) continue; - envLines.push(`${key}=${value}`); - } - - const lines = [ - "Paperclip wake event for a cloud adapter.", - "", - "Set these values in your run context:", - ...envLines, - `PAPERCLIP_API_KEY=`, - "", - `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, - "", - `task_id=${payload.taskId ?? ""}`, - `issue_id=${payload.issueId ?? ""}`, - `wake_reason=${payload.wakeReason ?? ""}`, - `wake_comment_id=${payload.wakeCommentId ?? ""}`, - `approval_id=${payload.approvalId ?? ""}`, - `approval_status=${payload.approvalStatus ?? ""}`, - `linked_issue_ids=${payload.issueIds.join(",")}`, - ]; - - lines.push("", "Run your Paperclip heartbeat procedure now."); - return lines.join("\n"); -} - -export function appendWakeText(baseText: string, wakeText: string): string { - const trimmedBase = baseText.trim(); - return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; -} - -function buildOpenResponsesWakeInputMessage(wakeText: string): Record { - return { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: wakeText, - }, - ], - }; -} - -export function appendWakeTextToOpenResponsesInput(input: unknown, wakeText: string): unknown { - if (typeof input === "string") { - return appendWakeText(input, wakeText); - } - - if (Array.isArray(input)) { - return [...input, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - if (typeof input === "object" && input !== null) { - const parsed = parseObject(input); - const content = parsed.content; - if (typeof content === "string") { - return { - ...parsed, - content: appendWakeText(content, wakeText), - }; - } - if (Array.isArray(content)) { - return { - ...parsed, - content: [ - ...content, - { - type: "input_text", - text: wakeText, - }, - ], - }; - } - return [parsed, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - return wakeText; -} - -export function isTextRequiredResponse(responseText: string): boolean { - const parsed = parseOpenClawResponse(responseText); - const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null; - if (parsedError && parsedError.toLowerCase().includes("text required")) { - return true; - } - return responseText.toLowerCase().includes("text required"); -} - -function extractResponseErrorMessage(responseText: string): string { - const parsed = parseOpenClawResponse(responseText); - if (!parsed) return responseText; - - const directError = parsed.error; - if (typeof directError === "string") return directError; - if (directError && typeof directError === "object") { - const nestedMessage = (directError as Record).message; - if (typeof nestedMessage === "string") return nestedMessage; - } - - const directMessage = parsed.message; - if (typeof directMessage === "string") return directMessage; - - return responseText; -} - -export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { - if (isTextRequiredResponse(responseText)) return true; - - const normalized = extractResponseErrorMessage(responseText).toLowerCase(); - const expectsStringInput = - normalized.includes("invalid input") && - normalized.includes("expected string") && - normalized.includes("undefined"); - if (expectsStringInput) return true; - - const missingInputField = - normalized.includes("input") && - (normalized.includes("required") || normalized.includes("missing")); - if (missingInputField) return true; - - return false; -} - -export async function sendJsonRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - signal: AbortSignal; -}): Promise { - return fetch(params.url, { - method: params.method, - headers: params.headers, - body: JSON.stringify(params.payload), - signal: params.signal, - }); -} - -export async function readAndLogResponseText(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const responseText = await params.response.text(); - if (responseText.trim().length > 0) { - await params.onLog( - "stdout", - `[openclaw] response (${params.response.status}) ${responseText.slice(0, 2000)}\n`, - ); - } else { - await params.onLog("stdout", `[openclaw] response (${params.response.status}) \n`); - } - return responseText; -} - -export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecutionState { - const method = asString(ctx.config.method, "POST").trim().toUpperCase() || "POST"; - const timeoutSecRaw = asNumber(ctx.config.timeoutSec, 0); - const timeoutSec = timeoutSecRaw > 0 ? Math.max(1, Math.floor(timeoutSecRaw)) : 0; - const headersConfig = parseObject(ctx.config.headers) as Record; - const payloadTemplate = parseObject(ctx.config.payloadTemplate); - const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader); - const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); - - const headers: Record = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersConfig)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const openClawAuthHeader = nonEmpty( - headers["x-openclaw-token"] ?? - headers["X-OpenClaw-Token"] ?? - headers["x-openclaw-auth"] ?? - headers["X-OpenClaw-Auth"], - ); - if (openClawAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader); - } - if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = webhookAuthHeader; - } - - const wakePayload = buildWakePayload(ctx); - const sessionKey = resolveSessionKey({ - strategy: sessionKeyStrategy, - configuredSessionKey: nonEmpty(ctx.config.sessionKey), - runId: ctx.runId, - issueId: wakePayload.issueId ?? wakePayload.taskId, - }); - - const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); - const wakeText = buildWakeText(wakePayload, paperclipEnv); - - return { - method, - timeoutSec, - headers, - payloadTemplate, - wakePayload, - sessionKey, - paperclipEnv, - wakeText, - }; -} - -export function buildWakeCompatibilityPayload(wakeText: string): Record { - return { - text: wakeText, - mode: "now", - }; -} diff --git a/packages/adapters/openclaw/src/server/execute-sse.ts b/packages/adapters/openclaw/src/server/execute-sse.ts deleted file mode 100644 index 2729f466..00000000 --- a/packages/adapters/openclaw/src/server/execute-sse.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeTextToOpenResponsesInput, - buildExecutionState, - isOpenResponsesEndpoint, - isTextRequiredResponse, - readAndLogResponseText, - redactForLog, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -type ConsumedSse = { - eventCount: number; - lastEventType: string | null; - lastData: string | null; - lastPayload: Record | null; - terminal: boolean; - failed: boolean; - errorMessage: string | null; -}; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function inferSseTerminal(input: { - eventType: string; - data: string; - parsedPayload: Record | null; -}): { terminal: boolean; failed: boolean; errorMessage: string | null } { - const normalizedType = input.eventType.trim().toLowerCase(); - const trimmedData = input.data.trim(); - const payload = input.parsedPayload; - const payloadType = nonEmpty(payload?.type)?.toLowerCase() ?? null; - const payloadStatus = nonEmpty(payload?.status)?.toLowerCase() ?? null; - - if (trimmedData === "[DONE]") { - return { terminal: true, failed: false, errorMessage: null }; - } - - const failType = - normalizedType.includes("error") || - normalizedType.includes("failed") || - normalizedType.includes("cancel"); - if (failType) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - (trimmedData.length > 0 ? trimmedData : "OpenClaw SSE error"), - }; - } - - const doneType = - normalizedType === "done" || - normalizedType.endsWith(".completed") || - normalizedType === "completed"; - if (doneType) { - return { terminal: true, failed: false, errorMessage: null }; - } - - if (payloadStatus) { - if ( - payloadStatus === "completed" || - payloadStatus === "succeeded" || - payloadStatus === "done" - ) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadStatus === "failed" || - payloadStatus === "cancelled" || - payloadStatus === "error" - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE status ${payloadStatus}`, - }; - } - } - - if (payloadType) { - if (payloadType.endsWith(".completed")) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadType.endsWith(".failed") || - payloadType.endsWith(".cancelled") || - payloadType.endsWith(".error") - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE type ${payloadType}`, - }; - } - } - - if (payload?.done === true) { - return { terminal: true, failed: false, errorMessage: null }; - } - - return { terminal: false, failed: false, errorMessage: null }; -} - -async function consumeSseResponse(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const reader = params.response.body?.getReader(); - if (!reader) { - throw new Error("OpenClaw SSE response body is missing"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let eventType = "message"; - let dataLines: string[] = []; - let eventCount = 0; - let lastEventType: string | null = null; - let lastData: string | null = null; - let lastPayload: Record | null = null; - let terminal = false; - let failed = false; - let errorMessage: string | null = null; - - const dispatchEvent = async (): Promise => { - if (dataLines.length === 0) { - eventType = "message"; - return false; - } - - const data = dataLines.join("\n"); - const trimmedData = data.trim(); - const parsedPayload = parseOpenClawResponse(trimmedData); - - eventCount += 1; - lastEventType = eventType; - lastData = data; - if (parsedPayload) lastPayload = parsedPayload; - - const preview = - trimmedData.length > 1000 ? `${trimmedData.slice(0, 1000)}...` : trimmedData; - await params.onLog("stdout", `[openclaw:sse] event=${eventType} data=${preview}\n`); - - const resolution = inferSseTerminal({ - eventType, - data, - parsedPayload, - }); - - dataLines = []; - eventType = "message"; - - if (resolution.terminal) { - terminal = true; - failed = resolution.failed; - errorMessage = resolution.errorMessage; - return true; - } - - return false; - }; - - let shouldStop = false; - while (!shouldStop) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - while (!shouldStop) { - const newlineIndex = buffer.indexOf("\n"); - if (newlineIndex === -1) break; - - let line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - if (line.endsWith("\r")) line = line.slice(0, -1); - - if (line.length === 0) { - shouldStop = await dispatchEvent(); - continue; - } - - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - buffer += decoder.decode(); - if (!shouldStop && buffer.trim().length > 0) { - for (const rawLine of buffer.split(/\r?\n/)) { - const line = rawLine.trimEnd(); - if (line.length === 0) { - shouldStop = await dispatchEvent(); - if (shouldStop) break; - continue; - } - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - if (!shouldStop && dataLines.length > 0) { - await dispatchEvent(); - } - - return { - eventCount, - lastEventType, - lastData, - lastPayload, - terminal, - failed, - errorMessage, - }; -} - -function buildSseBody(input: { - url: string; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; -}): { headers: Record; body: Record } { - const { url, state, context, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? `${templateText}\n\n${state.wakeText}` : state.wakeText; - - const isOpenResponses = isOpenResponsesEndpoint(url); - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - const body: Record = isOpenResponses - ? { - ...state.payloadTemplate, - stream: true, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - }, - } - : { - ...state.payloadTemplate, - stream: true, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "sse", - env: state.paperclipEnv, - context, - }, - }; - - const headers: Record = { - ...state.headers, - accept: "text/event-stream", - }; - - if (isOpenResponses && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - return { headers, body }; -} - -export async function executeSse(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "sse", - commandArgs: [state.method, url], - context, - }); - } - - const { headers, body } = buildSseBody({ - url, - state, - context, - configModel: ctx.config.model, - }); - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(body), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=sse)\n`); - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const response = await sendJsonRequest({ - url, - method: state.method, - headers, - payload: body, - signal: controller.signal, - }); - - if (!response.ok) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw SSE request failed with status ${response.status}`, - errorCode: isTextRequiredResponse(responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE endpoint did not return text/event-stream", - errorCode: "openclaw_sse_expected_event_stream", - resultJson: { - status: response.status, - statusText: response.statusText, - contentType, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const consumed = await consumeSseResponse({ response, onLog }); - if (consumed.failed) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: consumed.errorMessage ?? "OpenClaw SSE stream failed", - errorCode: "openclaw_sse_stream_failed", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - if (!consumed.terminal) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE stream closed without a terminal event", - errorCode: "openclaw_sse_stream_incomplete", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw SSE ${state.method} ${url}`, - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] SSE request timed out after ${state.timeoutSec}s\n` - : "[openclaw] SSE request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_sse_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts deleted file mode 100644 index a4f55989..00000000 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ /dev/null @@ -1,463 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeText, - appendWakeTextToOpenResponsesInput, - buildExecutionState, - buildWakeCompatibilityPayload, - deriveHookAgentUrlFromResponses, - isTextRequiredResponse, - isWakeCompatibilityRetryableResponse, - readAndLogResponseText, - redactForLog, - resolveEndpointKind, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawEndpointKind, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asBooleanFlag(value: unknown, fallback = false): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; - } - return fallback; -} - -function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - if (normalized === "now" || normalized === "next-heartbeat") return normalized; - return null; -} - -function parseOptionalPositiveInteger(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = Math.max(1, Math.floor(value)); - return Number.isFinite(normalized) ? normalized : null; - } - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value.trim(), 10); - if (Number.isFinite(parsed)) { - const normalized = Math.max(1, Math.floor(parsed)); - return Number.isFinite(normalized) ? normalized : null; - } - } - return null; -} - -function buildOpenResponsesWebhookBody(input: { - state: OpenClawExecutionState; - configModel: unknown; -}): Record { - const { state, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - return { - ...state.payloadTemplate, - stream: false, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - paperclip_stream_transport: "webhook", - }, - }; -} - -function buildHookWakeBody(state: OpenClawExecutionState): Record { - const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now"; - - return { - text: payloadText, - mode: wakeMode, - }; -} - -function buildHookAgentBody(input: { - state: OpenClawExecutionState; - includeSessionKey: boolean; -}): Record { - const { state, includeSessionKey } = input; - const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text); - const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText; - const payload: Record = { - message, - }; - - const name = nonEmpty(state.payloadTemplate.name); - if (name) payload.name = name; - - const agentId = nonEmpty(state.payloadTemplate.agentId); - if (agentId) payload.agentId = agentId; - - const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode); - if (wakeMode) payload.wakeMode = wakeMode; - - const deliver = state.payloadTemplate.deliver; - if (typeof deliver === "boolean") payload.deliver = deliver; - - const channel = nonEmpty(state.payloadTemplate.channel); - if (channel) payload.channel = channel; - - const to = nonEmpty(state.payloadTemplate.to); - if (to) payload.to = to; - - const model = nonEmpty(state.payloadTemplate.model); - if (model) payload.model = model; - - const thinking = nonEmpty(state.payloadTemplate.thinking); - if (thinking) payload.thinking = thinking; - - const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds); - if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds; - - const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey); - if (explicitSessionKey) { - payload.sessionKey = explicitSessionKey; - } else if (includeSessionKey) { - payload.sessionKey = state.sessionKey; - } - - return payload; -} - -function buildLegacyWebhookBody(input: { - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; -}): Record { - const { state, context } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - return { - ...state.payloadTemplate, - stream: false, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "webhook", - env: state.paperclipEnv, - context, - }, - }; -} - -function buildWebhookBody(input: { - endpointKind: OpenClawEndpointKind; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; - includeHookSessionKey: boolean; -}): Record { - const { endpointKind, state, context, configModel, includeHookSessionKey } = input; - if (endpointKind === "open_responses") { - return buildOpenResponsesWebhookBody({ state, configModel }); - } - if (endpointKind === "hook_wake") { - return buildHookWakeBody(state); - } - if (endpointKind === "hook_agent") { - return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey }); - } - - return buildLegacyWebhookBody({ state, context }); -} - -async function sendWebhookRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - onLog: AdapterExecutionContext["onLog"]; - signal: AbortSignal; -}): Promise<{ response: Response; responseText: string }> { - const response = await sendJsonRequest({ - url: params.url, - method: params.method, - headers: params.headers, - payload: params.payload, - signal: params.signal, - }); - - const responseText = await readAndLogResponseText({ response, onLog: params.onLog }); - return { response, responseText }; -} - -export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - const originalUrl = url; - const originalEndpointKind = resolveEndpointKind(originalUrl); - let targetUrl = originalUrl; - let endpointKind = resolveEndpointKind(targetUrl); - const remappedFromResponses = originalEndpointKind === "open_responses"; - - // In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent. - if (remappedFromResponses) { - const rewritten = deriveHookAgentUrlFromResponses(targetUrl); - if (rewritten) { - await onLog( - "stdout", - `[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`, - ); - targetUrl = rewritten; - endpointKind = resolveEndpointKind(targetUrl); - } - } - - const headers = { ...state.headers }; - if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "webhook", - commandArgs: [state.method, targetUrl], - context, - }); - } - - const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false); - const webhookBody = buildWebhookBody({ - endpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = endpointKind === "hook_wake"; - const initialBody = webhookBody; - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`); - - if (preferWakeCompatibilityBody) { - await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n"); - } - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const initialResponse = await sendWebhookRequest({ - url: targetUrl, - method: state.method, - headers, - payload: initialBody, - onLog, - signal: controller.signal, - }); - - let activeResponse = initialResponse; - let activeEndpointKind = endpointKind; - let activeUrl = targetUrl; - let activeHeaders = headers; - let usedLegacyResponsesFallback = false; - - if ( - remappedFromResponses && - targetUrl !== originalUrl && - initialResponse.response.status === 404 - ) { - await onLog( - "stdout", - `[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`, - ); - - activeEndpointKind = originalEndpointKind; - activeUrl = originalUrl; - usedLegacyResponsesFallback = true; - const fallbackHeaders = { ...state.headers }; - if ( - activeEndpointKind === "open_responses" && - !fallbackHeaders["x-openclaw-session-key"] && - !fallbackHeaders["X-OpenClaw-Session-Key"] - ) { - fallbackHeaders["x-openclaw-session-key"] = state.sessionKey; - } - - const fallbackBody = buildWebhookBody({ - endpointKind: activeEndpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - - await onLog( - "stdout", - `[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`, - ); - - activeResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: fallbackHeaders, - payload: fallbackBody, - onLog, - signal: controller.signal, - }); - activeHeaders = fallbackHeaders; - } - - if (!activeResponse.response.ok) { - const canRetryWithWakeCompatibility = - (activeEndpointKind === "open_responses" || activeEndpointKind === "generic") && - isWakeCompatibilityRetryableResponse(activeResponse.responseText); - - if (canRetryWithWakeCompatibility) { - await onLog( - "stdout", - "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n", - ); - - const retryResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: activeHeaders, - payload: wakeCompatibilityBody, - onLog, - signal: controller.signal, - }); - - if (retryResponse.response.ok) { - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`, - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - usedLegacyResponsesFallback, - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(retryResponse.responseText) - ? "OpenClaw endpoint rejected the wake compatibility payload as text-required." - : `OpenClaw webhook failed with status ${retryResponse.response.status}`, - errorCode: isTextRequiredResponse(retryResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(activeResponse.responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw webhook failed with status ${activeResponse.response.status}`, - errorCode: isTextRequiredResponse(activeResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl}`, - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - usedLegacyResponsesFallback, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n` - : "[openclaw] webhook request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_webhook_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts deleted file mode 100644 index c560a067..00000000 --- a/packages/adapters/openclaw/src/server/execute.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString } from "@paperclipai/adapter-utils/server-utils"; -import { isHookEndpoint } from "./execute-common.js"; -import { executeSse } from "./execute-sse.js"; -import { executeWebhook } from "./execute-webhook.js"; - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const url = asString(ctx.config.url, "").trim(); - if (!url) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw adapter missing url", - errorCode: "openclaw_url_missing", - }; - } - - const transportInput = ctx.config.streamTransport ?? ctx.config.transport; - const transport = normalizeTransport(transportInput); - if (!transport) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `OpenClaw adapter does not support transport: ${String(transportInput)}`, - errorCode: "openclaw_stream_transport_unsupported", - }; - } - - if (transport === "sse" && isHookEndpoint(url)) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw /hooks/* endpoints are not stream-capable. Use webhook transport for hooks.", - errorCode: "openclaw_sse_incompatible_endpoint", - }; - } - - if (transport === "webhook") { - return executeWebhook(ctx, url); - } - - return executeSse(ctx, url); -} diff --git a/packages/adapters/openclaw/src/server/hire-hook.ts b/packages/adapters/openclaw/src/server/hire-hook.ts deleted file mode 100644 index 2b6262c9..00000000 --- a/packages/adapters/openclaw/src/server/hire-hook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -const HIRE_CALLBACK_TIMEOUT_MS = 10_000; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -/** - * OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a - * configured callback URL so the cloud operator can notify the user (e.g. "you're hired"). - * Best-effort; failures are non-fatal to the approval flow. - */ -export async function onHireApproved( - payload: HireApprovedPayload, - adapterConfig: Record, -): Promise { - const config = parseObject(adapterConfig); - const url = nonEmpty(config.hireApprovedCallbackUrl); - if (!url) { - return { ok: true }; - } - - const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST"; - const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader); - - const headers: Record = { - "content-type": "application/json", - }; - if (authHeader && !headers.authorization && !headers.Authorization) { - headers.Authorization = authHeader; - } - const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record; - for (const [key, value] of Object.entries(extraHeaders)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const body = JSON.stringify({ - ...payload, - event: "hire_approved", - }); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method, - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeout); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - ok: false, - error: `HTTP ${response.status} ${response.statusText}`, - detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) }, - }; - } - return { ok: true }; - } catch (err) { - clearTimeout(timeout); - const message = err instanceof Error ? err.message : String(err); - const cause = err instanceof Error ? err.cause : undefined; - return { - ok: false, - error: message, - detail: cause != null ? { cause: String(cause) } : undefined, - }; - } -} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts deleted file mode 100644 index 05c4b355..00000000 --- a/packages/adapters/openclaw/src/server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; -export { onHireApproved } from "./hire-hook.js"; diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts deleted file mode 100644 index 5045c202..00000000 --- a/packages/adapters/openclaw/src/server/parse.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function parseOpenClawResponse(text: string): Record | null { - try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return null; - } - return parsed as Record; - } catch { - return null; - } -} - -export function isOpenClawUnknownSessionError(_text: string): boolean { - return false; -} diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts deleted file mode 100644 index ea5bcd85..00000000 --- a/packages/adapters/openclaw/src/server/test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -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 isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - -function normalizeHostname(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("[")) { - const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); - } - const firstColon = trimmed.indexOf(":"); - if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); - return trimmed.toLowerCase(); -} - -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function isHooksPath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return ( - value === "/hooks" || - value.startsWith("/hooks/") || - value.endsWith("/hooks") || - value.includes("/hooks/") - ); -} - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -function pushDeploymentDiagnostics( - checks: AdapterEnvironmentCheck[], - ctx: AdapterEnvironmentTestContext, - endpointUrl: URL | null, -) { - const mode = ctx.deployment?.mode; - const exposure = ctx.deployment?.exposure; - const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); - const allowSet = new Set( - (ctx.deployment?.allowedHostnames ?? []) - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; - - if (!mode) return; - - checks.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, - }); - - if (mode === "authenticated" && exposure === "private") { - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - checks.push({ - code: "openclaw_private_bind_hostname_not_allowed", - level: "warn", - message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, - }); - } - - if (!bindHost || isLoopbackHost(bindHost)) { - checks.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", - }); - } - - if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { - checks.push({ - code: "openclaw_private_no_allowed_hostnames", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs on another machine.", - }); - } - } - - if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { - checks.push({ - code: "openclaw_public_http_endpoint", - level: "warn", - message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", - }); - } -} - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const checks: AdapterEnvironmentCheck[] = []; - const config = parseObject(ctx.config); - const urlValue = asString(config.url, ""); - const streamTransportValue = config.streamTransport ?? config.transport; - const streamTransport = normalizeTransport(streamTransportValue); - - if (!urlValue) { - checks.push({ - code: "openclaw_url_missing", - level: "error", - message: "OpenClaw adapter requires an endpoint URL.", - hint: "Set adapterConfig.url to your OpenClaw transport endpoint.", - }); - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; - } - - let url: URL | null = null; - try { - url = new URL(urlValue); - } catch { - checks.push({ - code: "openclaw_url_invalid", - level: "error", - message: `Invalid URL: ${urlValue}`, - }); - } - - if (url && url.protocol !== "http:" && url.protocol !== "https:") { - checks.push({ - code: "openclaw_url_protocol_invalid", - level: "error", - message: `Unsupported URL protocol: ${url.protocol}`, - hint: "Use an http:// or https:// endpoint.", - }); - } - - if (url) { - checks.push({ - code: "openclaw_url_valid", - level: "info", - message: `Configured endpoint: ${url.toString()}`, - }); - - if (isLoopbackHost(url.hostname)) { - checks.push({ - code: "openclaw_loopback_endpoint", - level: "warn", - message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", - }); - } - - if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) { - checks.push({ - code: "openclaw_wake_endpoint_incompatible", - level: "error", - message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.", - hint: "Use webhook transport for /hooks/* endpoints.", - }); - } - } - - if (!streamTransport) { - checks.push({ - code: "openclaw_stream_transport_unsupported", - level: "error", - message: `Unsupported streamTransport: ${String(streamTransportValue)}`, - hint: "Use streamTransport=sse or streamTransport=webhook.", - }); - } else { - checks.push({ - code: "openclaw_stream_transport_configured", - level: "info", - message: `Configured stream transport: ${streamTransport}`, - }); - } - - pushDeploymentDiagnostics(checks, ctx, url); - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - checks.push({ - code: "openclaw_method_configured", - level: "info", - message: `Configured method: ${method}`, - }); - - if (url && (url.protocol === "http:" || url.protocol === "https:")) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - const response = await fetch(url, { method: "HEAD", signal: controller.signal }); - if (!response.ok && response.status !== 405 && response.status !== 501) { - checks.push({ - code: "openclaw_endpoint_probe_unexpected_status", - level: "warn", - message: `Endpoint probe returned HTTP ${response.status}.`, - hint: "Verify OpenClaw endpoint reachability and auth/network settings.", - }); - } else { - checks.push({ - code: "openclaw_endpoint_probe_ok", - level: "info", - message: "Endpoint responded to a HEAD probe.", - }); - } - } catch (err) { - checks.push({ - code: "openclaw_endpoint_probe_failed", - level: "warn", - message: err instanceof Error ? err.message : "Endpoint probe failed", - hint: "This may be expected in restricted networks; validate from the Paperclip server host.", - }); - } finally { - clearTimeout(timeout); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts deleted file mode 100644 index f1386780..00000000 --- a/packages/adapters/openclaw/src/ui/build-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -export function buildOpenClawConfig(v: CreateConfigValues): Record { - const ac: Record = {}; - if (v.url) ac.url = v.url; - ac.method = "POST"; - ac.timeoutSec = 0; - ac.streamTransport = "sse"; - ac.sessionKeyStrategy = "fixed"; - ac.sessionKey = "paperclip"; - return ac; -} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts deleted file mode 100644 index f3f1905e..00000000 --- a/packages/adapters/openclaw/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseOpenClawStdoutLine } from "./parse-stdout.js"; -export { buildOpenClawConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts deleted file mode 100644 index 55c7f3fe..00000000 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; -import { normalizeOpenClawStreamLine } from "../shared/stream.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 asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; - if (value === null || value === undefined) return ""; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readErrorText(value: unknown): string { - if (typeof value === "string") return value; - const obj = asRecord(value); - if (!obj) return stringifyUnknown(value); - return ( - asString(obj.message).trim() || - asString(obj.error).trim() || - asString(obj.code).trim() || - stringifyUnknown(obj) - ); -} - -function readDeltaText(payload: Record | null): string { - if (!payload) return ""; - - if (typeof payload.delta === "string") return payload.delta; - - const deltaObj = asRecord(payload.delta); - if (deltaObj) { - const nestedDelta = - asString(deltaObj.text) || - asString(deltaObj.value) || - asString(deltaObj.delta); - if (nestedDelta.length > 0) return nestedDelta; - } - - const part = asRecord(payload.part); - if (part) { - const partText = asString(part.text); - if (partText.length > 0) return partText; - } - - return ""; -} - -function extractResponseOutputText(response: Record | null): string { - if (!response) return ""; - - const output = Array.isArray(response.output) ? response.output : []; - const parts: string[] = []; - for (const itemRaw of output) { - const item = asRecord(itemRaw); - if (!item) continue; - const content = Array.isArray(item.content) ? item.content : []; - for (const partRaw of content) { - const part = asRecord(partRaw); - if (!part) continue; - const type = asString(part.type).trim().toLowerCase(); - if (type !== "output_text" && type !== "text" && type !== "refusal") continue; - const text = asString(part.text).trim(); - if (text) parts.push(text); - } - } - return parts.join("\n\n").trim(); -} - -function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { - const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s); - if (!match) return [{ kind: "stdout", ts, text: line }]; - - const eventType = (match[1] ?? "").trim(); - const dataText = (match[2] ?? "").trim(); - const parsed = asRecord(safeJsonParse(dataText)); - const normalizedEventType = eventType.toLowerCase(); - - if (dataText === "[DONE]") { - return []; - } - - const delta = readDeltaText(parsed); - if (normalizedEventType.endsWith(".delta") && delta.length > 0) { - return [{ kind: "assistant", ts, text: delta, delta: true }]; - } - - if ( - normalizedEventType.includes("error") || - normalizedEventType.includes("failed") || - normalizedEventType.includes("cancel") - ) { - const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText; - return message ? [{ kind: "stderr", ts, text: message }] : []; - } - - if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) { - const response = asRecord(parsed?.response); - const usage = asRecord(response?.usage); - const status = asString(response?.status, asString(parsed?.status, eventType)); - const statusLower = status.trim().toLowerCase(); - const errorText = - readErrorText(response?.error).trim() || - readErrorText(parsed?.error).trim() || - readErrorText(parsed?.message).trim(); - const isError = - statusLower === "failed" || - statusLower === "error" || - statusLower === "cancelled"; - - return [{ - kind: "result", - ts, - text: extractResponseOutputText(response), - inputTokens: asNumber(usage?.input_tokens), - outputTokens: asNumber(usage?.output_tokens), - cachedTokens: asNumber(usage?.cached_input_tokens), - costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)), - subtype: status || eventType, - isError, - errors: errorText ? [errorText] : [], - }]; - } - - return []; -} - -export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - const normalized = normalizeOpenClawStreamLine(line); - if (normalized.stream === "stderr") { - return [{ kind: "stderr", ts, text: normalized.line }]; - } - - const trimmed = normalized.line.trim(); - if (!trimmed) return []; - - if (trimmed.startsWith("[openclaw:sse]")) { - return parseOpenClawSseLine(trimmed, ts); - } - - if (trimmed.startsWith("[openclaw]")) { - return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }]; - } - - return [{ kind: "stdout", ts, text: normalized.line }]; -} diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index ef07f9bf..9ccc9e8d 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -1,5 +1,24 @@ # @paperclipai/adapter-opencode-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 + ## 0.2.7 ### Patch Changes diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index 7c6b48a3..e2816953 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-opencode-local", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 1661a85b..0c16e2d8 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -13,7 +13,7 @@ Use when: - You want OpenCode session resume across heartbeats via --session Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - OpenCode CLI is not installed on the machine diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 970896af..98285cfc 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -9,6 +9,7 @@ import { asStringArray, parseObject, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -99,6 +100,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -150,6 +152,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); for (const [key, value] of Object.entries(envConfig)) { @@ -233,7 +236,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["run", "--format", "json"]; @@ -264,6 +286,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], env: redactEnvForLogs(env), prompt, + promptMetrics, context, }); } diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index dd2eb2c6..a4d1a46d 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -7,6 +7,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; const MODELS_CACHE_TTL_MS = 60_000; +const MODELS_DISCOVERY_TIMEOUT_MS = 20_000; function resolveOpenCodeCommand(input: unknown): string { const envOverride = @@ -115,14 +116,14 @@ export async function discoverOpenCodeModels(input: { { cwd, env: runtimeEnv, - timeoutSec: 20, + timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000, graceSec: 3, onLog: async () => {}, }, ); if (result.timedOut) { - throw new Error("`opencode models` timed out."); + throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`); } if ((result.exitCode ?? 1) !== 0) { const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 3abfd6cd..0d425cf1 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record, ts: string): TranscriptEn kind: "tool_call", ts, name: toolName, + toolUseId: asString(part.callID) || asString(part.id) || undefined, input, }; diff --git a/packages/adapters/opencode-local/tsconfig.json b/packages/adapters/opencode-local/tsconfig.json index 2f355cfe..e1b71318 100644 --- a/packages/adapters/opencode-local/tsconfig.json +++ b/packages/adapters/opencode-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/pi-local/CHANGELOG.md b/packages/adapters/pi-local/CHANGELOG.md new file mode 100644 index 00000000..fb3c93a4 --- /dev/null +++ b/packages/adapters/pi-local/CHANGELOG.md @@ -0,0 +1,20 @@ +# @paperclipai/adapter-pi-local + +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + +## 0.3.0 + +### Minor Changes + +- Stable release preparation for 0.3.0 + +### Patch Changes + +- Updated dependencies + - @paperclipai/adapter-utils@0.3.0 diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index 1184c1ca..c286f84e 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-pi-local", - "version": "0.1.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 3794426f..a81750c3 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -14,7 +14,7 @@ Use when: - You need Pi's tool set (read, bash, edit, write, grep, find, ls) Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Pi CLI is not installed on the machine diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 23cad28b..85a0d844 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -9,10 +9,14 @@ import { asStringArray, parseObject, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -20,10 +24,6 @@ import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips"); @@ -50,34 +50,32 @@ function parseModelId(model: string | null): string | null { return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); await fs.mkdir(piSkillsHome, { recursive: true }); - - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + piSkillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`, + ); + } + + for (const entry of skillsEntries) { const target = path.join(piSkillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`, ); } catch (err) { await onLog( @@ -119,6 +117,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -178,6 +177,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); for (const [key, value] of Object.entries(envConfig)) { @@ -273,7 +273,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const userPrompt = joinPromptSections([ + renderedBootstrapPrompt, + sessionHandoffNote, + renderedHeartbeatPrompt, + ]); + const promptMetrics = { + systemPromptChars: renderedSystemPromptExtension.length, + promptChars: userPrompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedHeartbeatPrompt.length, + }; const commandNotes = (() => { if (!resolvedInstructionsFilePath) return [] as string[]; @@ -348,6 +357,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise; }; export type RunDatabaseBackupResult = { @@ -17,6 +20,50 @@ export type RunDatabaseBackupResult = { prunedCount: number; }; +export type RunDatabaseRestoreOptions = { + connectionString: string; + backupFile: string; + connectTimeoutSeconds?: number; +}; + +type SequenceDefinition = { + sequence_schema: string; + sequence_name: string; + data_type: string; + start_value: string; + minimum_value: string; + maximum_value: string; + increment: string; + cycle_option: "YES" | "NO"; + owner_schema: string | null; + owner_table: string | null; + owner_column: string | null; +}; + +type TableDefinition = { + schema_name: string; + tablename: string; +}; + +const DRIZZLE_SCHEMA = "drizzle"; +const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; + +const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900"; + +function sanitizeRestoreErrorMessage(error: unknown): string { + if (error && typeof error === "object") { + const record = error as Record; + const firstLine = typeof record.message === "string" + ? record.message.split(/\r?\n/, 1)[0]?.trim() + : ""; + const detail = typeof record.detail === "string" ? record.detail.trim() : ""; + const severity = typeof record.severity === "string" ? record.severity.trim() : ""; + const message = firstLine || detail || (error instanceof Error ? error.message : String(error)); + return severity ? `${severity}: ${message}` : message; + } + return error instanceof Error ? error.message : String(error); +} + function timestamp(date: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; @@ -47,10 +94,60 @@ function formatBackupSize(sizeBytes: number): string { return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`; } +function formatSqlLiteral(value: string): string { + const sanitized = value.replace(/\u0000/g, ""); + let tag = "$paperclip$"; + while (sanitized.includes(tag)) { + tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`; + } + return `${tag}${sanitized}${tag}`; +} + +function normalizeTableNameSet(values: string[] | undefined): Set { + return new Set( + (values ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); +} + +function normalizeNullifyColumnMap(values: Record | undefined): Map> { + const out = new Map>(); + if (!values) return out; + for (const [tableName, columns] of Object.entries(values)) { + const normalizedTable = tableName.trim(); + if (normalizedTable.length === 0) continue; + const normalizedColumns = new Set( + columns + .map((column) => column.trim()) + .filter((column) => column.length > 0), + ); + if (normalizedColumns.size > 0) { + out.set(normalizedTable, normalizedColumns); + } + } + return out; +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll("\"", "\"\"")}"`; +} + +function quoteQualifiedName(schemaName: string, objectName: string): string { + return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`; +} + +function tableKey(schemaName: string, tableName: string): string { + return `${schemaName}.${tableName}`; +} + export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const includeMigrationJournal = opts.includeMigrationJournal === true; + const excludedTableNames = normalizeTableNameSet(opts.excludeTables); + const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns); const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); try { @@ -58,13 +155,35 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const lines: string[] = []; const emit = (line: string) => lines.push(line); + const emitStatement = (statement: string) => { + emit(statement); + emit(STATEMENT_BREAKPOINT); + }; + const emitStatementBoundary = () => { + emit(STATEMENT_BREAKPOINT); + }; emit("-- Paperclip database backup"); emit(`-- Created: ${new Date().toISOString()}`); emit(""); - emit("BEGIN;"); + emitStatement("BEGIN;"); + emitStatement("SET LOCAL session_replication_role = replica;"); + emitStatement("SET LOCAL client_min_messages = warning;"); emit(""); + const allTables = await sql` + SELECT table_schema AS schema_name, table_name AS tablename + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND ( + table_schema = 'public' + OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE}) + ) + ORDER BY table_schema, table_name + `; + const tables = allTables; + const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename))); + // Get all enums const enums = await sql<{ typname: string; labels: string[] }[]>` SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels @@ -78,23 +197,65 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise for (const e of enums) { const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", "); - emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`); + emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`); } if (enums.length > 0) emit(""); - // Get tables in dependency order (referenced tables first) - const tables = await sql<{ tablename: string }[]>` - SELECT c.relname AS tablename - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'public' - AND c.relkind = 'r' - AND c.relname != '__drizzle_migrations' - ORDER BY c.relname + const allSequences = await sql` + SELECT + s.sequence_schema, + s.sequence_name, + s.data_type, + s.start_value, + s.minimum_value, + s.maximum_value, + s.increment, + s.cycle_option, + tblns.nspname AS owner_schema, + tbl.relname AS owner_table, + attr.attname AS owner_column + FROM information_schema.sequences s + JOIN pg_class seq ON seq.relname = s.sequence_name + JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema + LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a' + LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid + LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace + LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid + WHERE s.sequence_schema = 'public' + OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA}) + ORDER BY s.sequence_schema, s.sequence_name `; + const sequences = allSequences.filter( + (seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)), + ); + + const schemas = new Set(); + for (const table of tables) schemas.add(table.schema_name); + for (const seq of sequences) schemas.add(seq.sequence_schema); + const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public"); + if (extraSchemas.length > 0) { + emit("-- Schemas"); + for (const schemaName of extraSchemas) { + emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`); + } + emit(""); + } + + if (sequences.length > 0) { + emit("-- Sequences"); + for (const seq of sequences) { + const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name); + emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`); + emitStatement( + `CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`, + ); + } + emit(""); + } // Get full CREATE TABLE DDL via column info - for (const { tablename } of tables) { + for (const { schema_name, tablename } of tables) { + const qualifiedTableName = quoteQualifiedName(schema_name, tablename); const columns = await sql<{ column_name: string; data_type: string; @@ -108,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ${tablename} + WHERE table_schema = ${schema_name} AND table_name = ${tablename} ORDER BY ordinal_position `; - emit(`-- Table: ${tablename}`); - emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`); + emit(`-- Table: ${schema_name}.${tablename}`); + emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`); const colDefs: string[] = []; for (const col of columns) { @@ -149,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) - WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p' + WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p' GROUP BY c.conname `; for (const p of pk) { @@ -157,17 +318,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`); } - emit(`CREATE TABLE "${tablename}" (`); + emit(`CREATE TABLE ${qualifiedTableName} (`); emit(colDefs.join(",\n")); emit(");"); + emitStatementBoundary(); + emit(""); + } + + const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column); + if (ownedSequences.length > 0) { + emit("-- Sequence ownership"); + for (const seq of ownedSequences) { + emitStatement( + `ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`, + ); + } emit(""); } // Foreign keys (after all tables created) - const fks = await sql<{ + 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; @@ -175,137 +350,157 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise }[]>` 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 n ON n.oid = src.relnamespace + 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 n.nspname = 'public' - GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype - ORDER BY src.relname, c.conname + WHERE c.contype = 'f' AND ( + srcn.nspname = 'public' + OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA}) + ) + 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(", "); - emit( - `ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, + 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 - const uniques = await sql<{ + const allUniqueConstraints = await sql<{ constraint_name: string; + schema_name: string; tablename: string; column_names: string[]; }[]>` SELECT c.conname AS constraint_name, + n.nspname AS schema_name, t.relname AS tablename, array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) - WHERE n.nspname = 'public' AND c.contype = 'u' - GROUP BY c.conname, t.relname - ORDER BY t.relname, c.conname + WHERE c.contype = 'u' AND ( + n.nspname = 'public' + OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA}) + ) + GROUP BY c.conname, n.nspname, t.relname + ORDER BY n.nspname, t.relname, c.conname `; + const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename))); if (uniques.length > 0) { emit("-- Unique constraints"); for (const u of uniques) { const cols = u.column_names.map((c) => `"${c}"`).join(", "); - emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); + emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`); } emit(""); } // Indexes (non-primary, non-unique-constraint) - const indexes = await sql<{ indexdef: string }[]>` - SELECT indexdef + const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>` + SELECT schemaname AS schema_name, tablename, indexdef FROM pg_indexes - WHERE schemaname = 'public' - AND indexname NOT IN ( - SELECT conname FROM pg_constraint - WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public') + WHERE ( + schemaname = 'public' + OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA}) ) - ORDER BY tablename, indexname + AND indexname NOT IN ( + SELECT conname FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE n.nspname = pg_indexes.schemaname + ) + ORDER BY schemaname, tablename, indexname `; + const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename))); if (indexes.length > 0) { emit("-- Indexes"); for (const idx of indexes) { - emit(`${idx.indexdef};`); + emitStatement(`${idx.indexdef};`); } emit(""); } // Dump data for each table - for (const { tablename } of tables) { - const count = await sql<{ n: number }[]>` - SELECT count(*)::int AS n FROM ${sql(tablename)} - `; - if ((count[0]?.n ?? 0) === 0) continue; + for (const { schema_name, tablename } of tables) { + const qualifiedTableName = quoteQualifiedName(schema_name, tablename); + const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`); + if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue; // Get column info for this table const cols = await sql<{ column_name: string; data_type: string }[]>` SELECT column_name, data_type FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ${tablename} + WHERE table_schema = ${schema_name} AND table_name = ${tablename} ORDER BY ordinal_position `; const colNames = cols.map((c) => `"${c.column_name}"`).join(", "); - emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`); + emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`); - const rows = await sql`SELECT * FROM ${sql(tablename)}`.values(); + const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values(); + const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set(); for (const row of rows) { - const values = row.map((val: unknown) => { + const values = row.map((rawValue: unknown, index) => { + const columnName = cols[index]?.column_name; + const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue; if (val === null || val === undefined) return "NULL"; if (typeof val === "boolean") return val ? "true" : "false"; if (typeof val === "number") return String(val); - if (val instanceof Date) return `'${val.toISOString()}'`; - if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`; - return `'${String(val).replace(/'/g, "''")}'`; + if (val instanceof Date) return formatSqlLiteral(val.toISOString()); + if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val)); + return formatSqlLiteral(String(val)); }); - emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`); + emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`); } emit(""); } // Sequence values - const sequences = await sql<{ sequence_name: string }[]>` - SELECT sequence_name - FROM information_schema.sequences - WHERE sequence_schema = 'public' - ORDER BY sequence_name - `; - if (sequences.length > 0) { emit("-- Sequence values"); for (const seq of sequences) { - const val = await sql<{ last_value: string }[]>` - SELECT last_value::text FROM ${sql(seq.sequence_name)} - `; - if (val[0]) { - emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`); + const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name); + const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>( + `SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`, + ); + const skipSequenceValue = + seq.owner_table !== null + && excludedTableNames.has(seq.owner_table); + if (val[0] && !skipSequenceValue) { + emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`); } } emit(""); } - emit("COMMIT;"); + emitStatement("COMMIT;"); emit(""); // Write the backup file @@ -326,6 +521,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } } +export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { + const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + + try { + await sql`SELECT 1`; + const contents = await readFile(opts.backupFile, "utf8"); + const statements = contents + .split(STATEMENT_BREAKPOINT) + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); + + for (const statement of statements) { + await sql.unsafe(statement).execute(); + } + } catch (error) { + const statementPreview = typeof error === "object" && error !== null && typeof (error as Record).query === "string" + ? String((error as Record).query) + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0 && !line.startsWith("--")) + : null; + throw new Error( + `Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`, + ); + } finally { + await sql.end(); + } +} + export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string { const size = formatBackupSize(result.sizeBytes); const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : ""; diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 93edb2a5..83b4aa78 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -2,12 +2,17 @@ import { createHash } from "node:crypto"; import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; import { readFile, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import postgres from "postgres"; import * as schema from "./schema/index.js"; -const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname; +const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)); const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; -const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname; +const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url)); + +function createUtilitySql(url: string) { + return postgres(url, { max: 1, onnotice: () => {} }); +} function isSafeIdentifier(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); @@ -222,7 +227,7 @@ async function applyPendingMigrationsManually( journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]), ); - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql); const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`; @@ -471,7 +476,7 @@ export async function reconcilePendingMigrationHistory( return { repairedMigrations: [], remainingMigrations: [] }; } - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); const repairedMigrations: string[] = []; try { @@ -578,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType): P } export async function inspectMigrations(url: string): Promise { - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const availableMigrations = await listMigrationFiles(); @@ -641,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise { const initialState = await inspectMigrations(url); if (initialState.status === "upToDate") return; - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const db = drizzlePg(sql); @@ -679,7 +684,7 @@ export type MigrationBootstrapResult = | { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number }; export async function migratePostgresIfEmpty(url: string): Promise { - const sql = postgres(url, { max: 1 }); + const sql = createUtilitySql(url); try { const migrationTableSchema = await discoverMigrationTableSchema(sql); @@ -702,8 +707,7 @@ export async function migratePostgresIfEmpty(url: string): Promise` select 1 as one from pg_database where datname = ${databaseName} limit 1 `; if (existing.length > 0) return "exists"; - await sql.unsafe(`create database "${databaseName}"`); + await sql.unsafe(`create database "${databaseName}" encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`); return "created"; } finally { await sql.end(); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3cafa7af..f280cee1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,8 +12,10 @@ export { } from "./client.js"; export { runDatabaseBackup, + runDatabaseRestore, formatDatabaseBackupResult, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, + type RunDatabaseRestoreOptions, } from "./backup-lib.js"; export * from "./schema/index.js"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index b4c7b975..f51b629e 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1,21 +1,29 @@ import { applyPendingMigrations, inspectMigrations } from "./client.js"; +import { resolveMigrationConnection } from "./migration-runtime.js"; -const url = process.env.DATABASE_URL; +async function main(): Promise { + const resolved = await resolveMigrationConnection(); -if (!url) { - throw new Error("DATABASE_URL is required for db:migrate"); -} + console.log(`Migrating database via ${resolved.source}`); -const before = await inspectMigrations(url); -if (before.status === "upToDate") { - console.log("No pending migrations"); -} else { - console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`); - await applyPendingMigrations(url); + try { + const before = await inspectMigrations(resolved.connectionString); + if (before.status === "upToDate") { + console.log("No pending migrations"); + return; + } - const after = await inspectMigrations(url); - if (after.status !== "upToDate") { - throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`); + console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`); + await applyPendingMigrations(resolved.connectionString); + + const after = await inspectMigrations(resolved.connectionString); + if (after.status !== "upToDate") { + throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`); + } + console.log("Migrations complete"); + } finally { + await resolved.stop(); } - console.log("Migrations complete"); } + +await main(); diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts new file mode 100644 index 00000000..e07bdf04 --- /dev/null +++ b/packages/db/src/migration-runtime.ts @@ -0,0 +1,136 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { ensurePostgresDatabase } from "./client.js"; +import { resolveDatabaseTarget } from "./runtime-config.js"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type MigrationConnection = { + connectionString: string; + source: string; + stop: () => Promise; +}; + +function readRunningPostmasterPid(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); + if (!Number.isInteger(pid) || pid <= 0) return null; + process.kill(pid, 0); + return pid; + } catch { + return null; + } +} + +function readPidFilePort(postmasterPidFile: string): number | null { + if (!existsSync(postmasterPidFile)) return null; + try { + const lines = readFileSync(postmasterPidFile, "utf8").split("\n"); + const port = Number(lines[3]?.trim()); + return Number.isInteger(port) && port > 0 ? port : null; + } catch { + return null; + } +} + +async function loadEmbeddedPostgresCtor(): Promise { + const require = createRequire(import.meta.url); + const resolveCandidates = [ + path.resolve(fileURLToPath(new URL("../..", import.meta.url))), + path.resolve(fileURLToPath(new URL("../../server", import.meta.url))), + path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))), + process.cwd(), + ]; + + try { + const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates }); + const mod = await import(pathToFileURL(resolvedModulePath).href); + return mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", + ); + } +} + +async function ensureEmbeddedPostgresConnection( + dataDir: string, + preferredPort: number, +): Promise { + const EmbeddedPostgres = await loadEmbeddedPostgresCtor(); + const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); + const runningPid = readRunningPostmasterPid(postmasterPidFile); + const runningPort = readPidFilePort(postmasterPidFile); + + if (runningPid) { + const port = runningPort ?? preferredPort; + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + return { + connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`, + source: `embedded-postgres@${port}`, + stop: async () => {}, + }; + } + + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port: preferredPort, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + + if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { + await instance.initialise(); + } + if (existsSync(postmasterPidFile)) { + rmSync(postmasterPidFile, { force: true }); + } + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + + return { + connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`, + source: `embedded-postgres@${preferredPort}`, + stop: async () => { + await instance.stop(); + }, + }; +} + +export async function resolveMigrationConnection(): Promise { + const target = resolveDatabaseTarget(); + if (target.mode === "postgres") { + return { + connectionString: target.connectionString, + source: target.source, + stop: async () => {}, + }; + } + + return ensureEmbeddedPostgresConnection(target.dataDir, target.port); +} diff --git a/packages/db/src/migration-status.ts b/packages/db/src/migration-status.ts new file mode 100644 index 00000000..3d0cc8f4 --- /dev/null +++ b/packages/db/src/migration-status.ts @@ -0,0 +1,45 @@ +import { inspectMigrations } from "./client.js"; +import { resolveMigrationConnection } from "./migration-runtime.js"; + +const jsonMode = process.argv.includes("--json"); + +async function main(): Promise { + const connection = await resolveMigrationConnection(); + + try { + const state = await inspectMigrations(connection.connectionString); + const payload = + state.status === "upToDate" + ? { + source: connection.source, + status: "upToDate" as const, + tableCount: state.tableCount, + pendingMigrations: [] as string[], + } + : { + source: connection.source, + status: "needsMigrations" as const, + tableCount: state.tableCount, + pendingMigrations: state.pendingMigrations, + reason: state.reason, + }; + + if (jsonMode) { + console.log(JSON.stringify(payload)); + return; + } + + if (payload.status === "upToDate") { + console.log(`Database is up to date via ${payload.source}`); + return; + } + + console.log( + `Pending migrations via ${payload.source}: ${payload.pendingMigrations.join(", ")}`, + ); + } finally { + await connection.stop(); + } +} + +await main(); diff --git a/packages/db/src/migrations/0026_high_anita_blake.sql b/packages/db/src/migrations/0026_high_anita_blake.sql deleted file mode 100644 index 17be3222..00000000 --- a/packages/db/src/migrations/0026_high_anita_blake.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "logo_url" text; diff --git a/packages/db/src/migrations/0026_lying_pete_wisdom.sql b/packages/db/src/migrations/0026_lying_pete_wisdom.sql new file mode 100644 index 00000000..d9d059a2 --- /dev/null +++ b/packages/db/src/migrations/0026_lying_pete_wisdom.sql @@ -0,0 +1,39 @@ +CREATE TABLE "workspace_runtime_services" ( + "id" uuid PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "project_id" uuid, + "project_workspace_id" uuid, + "issue_id" uuid, + "scope_type" text NOT NULL, + "scope_id" text, + "service_name" text NOT NULL, + "status" text NOT NULL, + "lifecycle" text NOT NULL, + "reuse_key" text, + "command" text, + "cwd" text, + "port" integer, + "url" text, + "provider" text NOT NULL, + "provider_ref" text, + "owner_agent_id" uuid, + "started_by_run_id" uuid, + "last_used_at" timestamp with time zone DEFAULT now() NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "stopped_at" timestamp with time zone, + "stop_policy" jsonb, + "health_status" text DEFAULT 'unknown' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint +CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/0027_tranquil_tenebrous.sql b/packages/db/src/migrations/0027_tranquil_tenebrous.sql new file mode 100644 index 00000000..81378591 --- /dev/null +++ b/packages/db/src/migrations/0027_tranquil_tenebrous.sql @@ -0,0 +1,2 @@ +ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb; \ No newline at end of file diff --git a/packages/db/src/migrations/0028_harsh_goliath.sql b/packages/db/src/migrations/0028_harsh_goliath.sql new file mode 100644 index 00000000..b92ad944 --- /dev/null +++ b/packages/db/src/migrations/0028_harsh_goliath.sql @@ -0,0 +1,54 @@ +CREATE TABLE "document_revisions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "revision_number" integer NOT NULL, + "body" text NOT NULL, + "change_summary" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "title" text, + "format" text DEFAULT 'markdown' NOT NULL, + "latest_body" text NOT NULL, + "latest_revision_id" uuid, + "latest_revision_number" integer DEFAULT 1 NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "updated_by_agent_id" uuid, + "updated_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "issue_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint +CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint +CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint +CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint +CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/0029_plugin_tables.sql b/packages/db/src/migrations/0029_plugin_tables.sql new file mode 100644 index 00000000..8ee0d937 --- /dev/null +++ b/packages/db/src/migrations/0029_plugin_tables.sql @@ -0,0 +1,177 @@ +-- Rollback: +-- DROP INDEX IF EXISTS "plugin_logs_level_idx"; +-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq"; +-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_external_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_type_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx"; +-- DROP INDEX IF EXISTS "plugins_status_idx"; +-- DROP INDEX IF EXISTS "plugins_plugin_key_idx"; +-- DROP TABLE IF EXISTS "plugin_logs"; +-- DROP TABLE IF EXISTS "plugin_company_settings"; +-- DROP TABLE IF EXISTS "plugin_webhook_deliveries"; +-- DROP TABLE IF EXISTS "plugin_job_runs"; +-- DROP TABLE IF EXISTS "plugin_jobs"; +-- DROP TABLE IF EXISTS "plugin_entities"; +-- DROP TABLE IF EXISTS "plugin_state"; +-- DROP TABLE IF EXISTS "plugin_config"; +-- DROP TABLE IF EXISTS "plugins"; + +CREATE TABLE "plugins" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_key" text NOT NULL, + "package_name" text NOT NULL, + "package_path" text, + "version" text NOT NULL, + "api_version" integer DEFAULT 1 NOT NULL, + "categories" jsonb DEFAULT '[]'::jsonb NOT NULL, + "manifest_json" jsonb NOT NULL, + "status" text DEFAULT 'installed' NOT NULL, + "install_order" integer, + "last_error" text, + "installed_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "config_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "namespace" text DEFAULT 'default' NOT NULL, + "state_key" text NOT NULL, + "value_json" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key") +); +--> statement-breakpoint +CREATE TABLE "plugin_entities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "entity_type" text NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "external_id" text, + "title" text, + "status" text, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "job_key" text NOT NULL, + "schedule" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "last_run_at" timestamp with time zone, + "next_run_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_job_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "job_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "trigger" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "logs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "webhook_key" text NOT NULL, + "external_id" text, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "payload" jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_company_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "enabled" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "plugin_id" uuid NOT NULL, + "level" text NOT NULL DEFAULT 'info', + "message" text NOT NULL, + "meta" jsonb, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint +CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint +CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint +CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint +CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level"); diff --git a/packages/db/src/migrations/0030_hot_slipstream.sql b/packages/db/src/migrations/0030_hot_slipstream.sql new file mode 100644 index 00000000..c00d29a3 --- /dev/null +++ b/packages/db/src/migrations/0030_hot_slipstream.sql @@ -0,0 +1 @@ +ALTER TABLE "companies" ADD COLUMN "logo_url" text; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0026_snapshot.json b/packages/db/src/migrations/meta/0027_snapshot.json similarity index 93% rename from packages/db/src/migrations/meta/0026_snapshot.json rename to packages/db/src/migrations/meta/0027_snapshot.json index 400b7eb5..4a2a159c 100644 --- a/packages/db/src/migrations/meta/0026_snapshot.json +++ b/packages/db/src/migrations/meta/0027_snapshot.json @@ -1,6 +1,6 @@ { - "id": "ada32d9a-1735-4149-91c7-83ae8f5dd482", - "prevId": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82", + "id": "8186209d-f7ec-4048-bd4f-c96530f45304", + "prevId": "5f8dd541-9e28-4a42-890b-fc4a301604ac", "version": "7", "dialect": "postgresql", "tables": { @@ -2140,12 +2140,6 @@ "primaryKey": false, "notNull": false }, - "logo_url": { - "name": "logo_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -4638,6 +4632,12 @@ "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", @@ -5755,6 +5755,12 @@ "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", @@ -5839,6 +5845,350 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": {}, diff --git a/packages/db/src/migrations/meta/0028_snapshot.json b/packages/db/src/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..122f75ef --- /dev/null +++ b/packages/db/src/migrations/meta/0028_snapshot.json @@ -0,0 +1,6710 @@ +{ + "id": "6fe59d88-aadc-4acb-acf4-ea60b7dbc7dc", + "prevId": "8186209d-f7ec-4048-bd4f-c96530f45304", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_updated_idx": { + "name": "documents_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_company_created_idx": { + "name": "documents_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0029_snapshot.json b/packages/db/src/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..e5a4f636 --- /dev/null +++ b/packages/db/src/migrations/meta/0029_snapshot.json @@ -0,0 +1,7899 @@ +{ + "id": "fdb36f4e-6463-497d-b704-22d33be9b450", + "prevId": "6fe59d88-aadc-4acb-acf4-ea60b7dbc7dc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_updated_idx": { + "name": "documents_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_company_created_idx": { + "name": "documents_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_company_settings": { + "name": "plugin_company_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_config_plugin_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0030_snapshot.json b/packages/db/src/migrations/meta/0030_snapshot.json new file mode 100644 index 00000000..539d4be0 --- /dev/null +++ b/packages/db/src/migrations/meta/0030_snapshot.json @@ -0,0 +1,7905 @@ +{ + "id": "eb9b85ec-2048-4168-bff1-0e987773342a", + "prevId": "fdb36f4e-6463-497d-b704-22d33be9b450", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_updated_idx": { + "name": "documents_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_company_created_idx": { + "name": "documents_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_company_settings": { + "name": "plugin_company_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_config_plugin_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 22a695b4..852fa998 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -187,8 +187,36 @@ { "idx": 26, "version": "7", - "when": 1772823634634, - "tag": "0026_high_anita_blake", + "when": 1773089625430, + "tag": "0026_lying_pete_wisdom", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1773150731736, + "tag": "0027_tranquil_tenebrous", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773432085646, + "tag": "0028_harsh_goliath", + "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1773417600000, + "tag": "0029_plugin_tables", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1773668505562, + "tag": "0030_hot_slipstream", "breakpoints": true } ] diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts new file mode 100644 index 00000000..4627e691 --- /dev/null +++ b/packages/db/src/runtime-config.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveDatabaseTarget } from "./runtime-config.js"; + +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_ENV = { ...process.env }; + +function writeJson(filePath: string, value: unknown) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)); +} + +function writeText(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value); +} + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("resolveDatabaseTarget", () => { + it("uses DATABASE_URL from process env first", () => { + process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip"; + + const target = resolveDatabaseTarget(); + + expect(target).toMatchObject({ + mode: "postgres", + connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip", + source: "DATABASE_URL", + }); + }); + + it("uses DATABASE_URL from repo-local .paperclip/.env", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-")); + const projectDir = path.join(tempDir, "repo"); + fs.mkdirSync(projectDir, { recursive: true }); + process.chdir(projectDir); + delete process.env.PAPERCLIP_CONFIG; + writeJson(path.join(projectDir, ".paperclip", "config.json"), { + database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 }, + }); + writeText( + path.join(projectDir, ".paperclip", ".env"), + 'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n', + ); + + const target = resolveDatabaseTarget(); + + expect(target).toMatchObject({ + mode: "postgres", + connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip", + source: "paperclip-env", + }); + }); + + it("uses config postgres connection string when configured", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-")); + const configPath = path.join(tempDir, "instance", "config.json"); + process.env.PAPERCLIP_CONFIG = configPath; + writeJson(configPath, { + database: { + mode: "postgres", + connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip", + }, + }); + + const target = resolveDatabaseTarget(); + + expect(target).toMatchObject({ + mode: "postgres", + connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip", + source: "config.database.connectionString", + }); + }); + + it("falls back to embedded postgres settings from config", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-")); + const configPath = path.join(tempDir, "instance", "config.json"); + process.env.PAPERCLIP_CONFIG = configPath; + writeJson(configPath, { + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "~/paperclip-test-db", + embeddedPostgresPort: 55444, + }, + }); + + const target = resolveDatabaseTarget(); + + expect(target).toMatchObject({ + mode: "embedded-postgres", + dataDir: path.resolve(os.homedir(), "paperclip-test-db"), + port: 55444, + source: "embedded-postgres@55444", + }); + }); +}); diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts new file mode 100644 index 00000000..c6c64a38 --- /dev/null +++ b/packages/db/src/runtime-config.ts @@ -0,0 +1,267 @@ +import { existsSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_INSTANCE_ID = "default"; +const CONFIG_BASENAME = "config.json"; +const ENV_BASENAME = ".env"; +const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/; + +type PartialConfig = { + database?: { + mode?: "embedded-postgres" | "postgres"; + connectionString?: string; + embeddedPostgresDataDir?: string; + embeddedPostgresPort?: number; + pgliteDataDir?: string; + pglitePort?: number; + }; +}; + +export type ResolvedDatabaseTarget = + | { + mode: "postgres"; + connectionString: string; + source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString"; + configPath: string; + envPath: string; + } + | { + mode: "embedded-postgres"; + dataDir: string; + port: number; + source: `embedded-postgres@${number}`; + configPath: string; + envPath: string; + }; + +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolvePaperclipHomeDir(): string { + const envHome = process.env.PAPERCLIP_HOME?.trim(); + if (envHome) return path.resolve(expandHomePrefix(envHome)); + return path.resolve(os.homedir(), ".paperclip"); +} + +function resolvePaperclipInstanceId(): string { + const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID; + if (!INSTANCE_ID_RE.test(raw)) { + throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`); + } + return raw; +} + +function resolveDefaultConfigPath(): string { + return path.resolve( + resolvePaperclipHomeDir(), + "instances", + resolvePaperclipInstanceId(), + CONFIG_BASENAME, + ); +} + +function resolveDefaultEmbeddedPostgresDir(): string { + return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db"); +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function findConfigFileFromAncestors(startDir: string): string | null { + let currentDir = path.resolve(startDir); + + while (true) { + const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME); + if (existsSync(candidate)) return candidate; + + const nextDir = path.resolve(currentDir, ".."); + if (nextDir === currentDir) return null; + currentDir = nextDir; + } +} + +function resolvePaperclipConfigPath(): string { + if (process.env.PAPERCLIP_CONFIG?.trim()) { + return path.resolve(process.env.PAPERCLIP_CONFIG.trim()); + } + return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(); +} + +function resolvePaperclipEnvPath(configPath: string): string { + return path.resolve(path.dirname(configPath), ENV_BASENAME); +} + +function parseEnvFile(contents: string): Record { + const entries: Record = {}; + + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) { + entries[key] = ""; + continue; + } + + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + + return entries; +} + +function readEnvEntries(envPath: string): Record { + if (!existsSync(envPath)) return {}; + return parseEnvFile(readFileSync(envPath, "utf8")); +} + +function migrateLegacyConfig(raw: unknown): PartialConfig | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + + const config = { ...(raw as Record) }; + const databaseRaw = config.database; + if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) { + return config; + } + + const database = { ...(databaseRaw as Record) }; + if (database.mode === "pglite") { + database.mode = "embedded-postgres"; + + if ( + typeof database.embeddedPostgresDataDir !== "string" && + typeof database.pgliteDataDir === "string" + ) { + database.embeddedPostgresDataDir = database.pgliteDataDir; + } + if ( + typeof database.embeddedPostgresPort !== "number" && + typeof database.pglitePort === "number" && + Number.isFinite(database.pglitePort) + ) { + database.embeddedPostgresPort = database.pglitePort; + } + } + + config.database = database; + return config as PartialConfig; +} + +function asPositiveInt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const rounded = Math.trunc(value); + return rounded > 0 ? rounded : null; +} + +function readConfig(configPath: string): PartialConfig | null { + if (!existsSync(configPath)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(configPath, "utf8")); + } catch (err) { + throw new Error( + `Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const migrated = migrateLegacyConfig(parsed); + if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) { + throw new Error(`Invalid config at ${configPath}: expected a JSON object`); + } + + const database = + typeof migrated.database === "object" && + migrated.database !== null && + !Array.isArray(migrated.database) + ? migrated.database + : undefined; + + return { + database: database + ? { + mode: database.mode === "postgres" ? "postgres" : "embedded-postgres", + connectionString: + typeof database.connectionString === "string" ? database.connectionString : undefined, + embeddedPostgresDataDir: + typeof database.embeddedPostgresDataDir === "string" + ? database.embeddedPostgresDataDir + : undefined, + embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined, + pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined, + pglitePort: asPositiveInt(database.pglitePort) ?? undefined, + } + : undefined, + }; +} + +export function resolveDatabaseTarget(): ResolvedDatabaseTarget { + const configPath = resolvePaperclipConfigPath(); + const envPath = resolvePaperclipEnvPath(configPath); + const envEntries = readEnvEntries(envPath); + + const envUrl = process.env.DATABASE_URL?.trim(); + if (envUrl) { + return { + mode: "postgres", + connectionString: envUrl, + source: "DATABASE_URL", + configPath, + envPath, + }; + } + + const fileEnvUrl = envEntries.DATABASE_URL?.trim(); + if (fileEnvUrl) { + return { + mode: "postgres", + connectionString: fileEnvUrl, + source: "paperclip-env", + configPath, + envPath, + }; + } + + const config = readConfig(configPath); + const connectionString = config?.database?.connectionString?.trim(); + if (config?.database?.mode === "postgres" && connectionString) { + return { + mode: "postgres", + connectionString, + source: "config.database.connectionString", + configPath, + envPath, + }; + } + + const port = config?.database?.embeddedPostgresPort ?? 54329; + const dataDir = resolveHomeAwarePath( + config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(), + ); + + return { + mode: "embedded-postgres", + dataDir, + port, + source: `embedded-postgres@${port}`, + configPath, + envPath, + }; +} diff --git a/packages/db/src/schema/document_revisions.ts b/packages/db/src/schema/document_revisions.ts new file mode 100644 index 00000000..6e739989 --- /dev/null +++ b/packages/db/src/schema/document_revisions.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; +import { documents } from "./documents.js"; + +export const documentRevisions = pgTable( + "document_revisions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + revisionNumber: integer("revision_number").notNull(), + body: text("body").notNull(), + changeSummary: text("change_summary"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on( + table.documentId, + table.revisionNumber, + ), + companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on( + table.companyId, + table.documentId, + table.createdAt, + ), + }), +); diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts new file mode 100644 index 00000000..53d5f358 --- /dev/null +++ b/packages/db/src/schema/documents.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; + +export const documents = pgTable( + "documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + title: text("title"), + format: text("format").notNull().default("markdown"), + latestBody: text("latest_body").notNull(), + latestRevisionId: uuid("latest_revision_id"), + latestRevisionNumber: integer("latest_revision_number").notNull().default(1), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + updatedByUserId: text("updated_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt), + companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index eb12c064..f173db45 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js"; export { agentWakeupRequests } from "./agent_wakeup_requests.js"; export { projects } from "./projects.js"; export { projectWorkspaces } from "./project_workspaces.js"; +export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; export { issues } from "./issues.js"; @@ -23,6 +24,9 @@ export { issueComments } from "./issue_comments.js"; export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; export { issueAttachments } from "./issue_attachments.js"; +export { documents } from "./documents.js"; +export { documentRevisions } from "./document_revisions.js"; +export { issueDocuments } from "./issue_documents.js"; export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { costEvents } from "./cost_events.js"; @@ -31,3 +35,11 @@ export { approvalComments } from "./approval_comments.js"; export { activityLog } from "./activity_log.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; +export { plugins } from "./plugins.js"; +export { pluginConfig } from "./plugin_config.js"; +export { pluginCompanySettings } from "./plugin_company_settings.js"; +export { pluginState } from "./plugin_state.js"; +export { pluginEntities } from "./plugin_entities.js"; +export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js"; +export { pluginWebhookDeliveries } from "./plugin_webhooks.js"; +export { pluginLogs } from "./plugin_logs.js"; diff --git a/packages/db/src/schema/issue_documents.ts b/packages/db/src/schema/issue_documents.ts new file mode 100644 index 00000000..b015f8e5 --- /dev/null +++ b/packages/db/src/schema/issue_documents.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { documents } from "./documents.js"; + +export const issueDocuments = pgTable( + "issue_documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + key: text("key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on( + table.companyId, + table.issueId, + table.key, + ), + documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId), + companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on( + table.companyId, + table.issueId, + table.updatedAt, + ), + }), +); diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 5d6849e3..80093e67 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -40,6 +40,7 @@ export const issues = pgTable( requestDepth: integer("request_depth").notNull().default(0), billingCode: text("billing_code"), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type>(), + executionWorkspaceSettings: jsonb("execution_workspace_settings").$type>(), startedAt: timestamp("started_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), cancelledAt: timestamp("cancelled_at", { withTimezone: true }), diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts new file mode 100644 index 00000000..87d4b4af --- /dev/null +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -0,0 +1,41 @@ +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_company_settings` table — stores operator-managed plugin settings + * scoped to a specific company. + * + * This is distinct from `plugin_config`, which stores instance-wide plugin + * configuration. Each company can have at most one settings row per plugin. + * + * Rows represent explicit overrides from the default company behavior: + * - no row => plugin is enabled for the company by default + * - row with `enabled = false` => plugin is disabled for that company + * - row with `enabled = true` => plugin remains enabled and stores company settings + */ +export const pluginCompanySettings = pgTable( + "plugin_company_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + settingsJson: jsonb("settings_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("plugin_company_settings_company_idx").on(table.companyId), + pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId), + companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on( + table.companyId, + table.pluginId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts new file mode 100644 index 00000000..24407b97 --- /dev/null +++ b/packages/db/src/schema/plugin_config.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_config` table — stores operator-provided instance configuration + * for each plugin (one row per plugin, enforced by a unique index on + * `plugin_id`). + * + * The `config_json` column holds the values that the operator enters in the + * plugin settings UI. These values are validated at runtime against the + * plugin's `instanceConfigSchema` from the manifest. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginConfig = pgTable( + "plugin_config", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + configJson: jsonb("config_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId), + }), +); diff --git a/packages/db/src/schema/plugin_entities.ts b/packages/db/src/schema/plugin_entities.ts new file mode 100644 index 00000000..5f732304 --- /dev/null +++ b/packages/db/src/schema/plugin_entities.ts @@ -0,0 +1,54 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; + +/** + * `plugin_entities` table — persistent high-level mapping between Paperclip + * objects and external plugin-defined entities. + * + * This table is used by plugins (e.g. `linear`, `github`) to store pointers + * to their respective external IDs for projects, issues, etc. and to store + * their custom data. + * + * Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities` + * is intended for structured object mappings that the host can understand + * and query for cross-plugin UI integration. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginEntities = pgTable( + "plugin_entities", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + scopeKind: text("scope_kind").$type().notNull(), + scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id) + externalId: text("external_id"), // ID in the external system + title: text("title"), + status: text("status"), + data: jsonb("data").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId), + typeIdx: index("plugin_entities_type_idx").on(table.entityType), + scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId), + externalIdx: uniqueIndex("plugin_entities_external_idx").on( + table.pluginId, + table.entityType, + table.externalId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts new file mode 100644 index 00000000..fec0d0c4 --- /dev/null +++ b/packages/db/src/schema/plugin_jobs.ts @@ -0,0 +1,102 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared"; + +/** + * `plugin_jobs` table — registration and runtime configuration for + * scheduled jobs declared by plugins in their manifests. + * + * Each row represents one scheduled job entry for a plugin. The + * `job_key` matches the key declared in the manifest's `jobs` array. + * The `schedule` column stores the cron expression or interval string + * used by the job scheduler to decide when to fire the job. + * + * Status values: + * - `active` — job is enabled and will run on schedule + * - `paused` — job is temporarily disabled by the operator + * - `error` — job has been disabled due to repeated failures + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` + */ +export const pluginJobs = pgTable( + "plugin_jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `jobs` array. */ + jobKey: text("job_key").notNull(), + /** Cron expression (e.g. `"0 * * * *"`) or interval string. */ + schedule: text("schedule").notNull(), + /** Current scheduling state. */ + status: text("status").$type().notNull().default("active"), + /** Timestamp of the most recent successful execution. */ + lastRunAt: timestamp("last_run_at", { withTimezone: true }), + /** Pre-computed timestamp of the next scheduled execution. */ + nextRunAt: timestamp("next_run_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId), + nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt), + uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey), + }), +); + +/** + * `plugin_job_runs` table — immutable execution history for plugin-owned jobs. + * + * Each row is created when a job run begins and updated when it completes. + * Rows are never modified after `status` reaches a terminal value + * (`succeeded` | `failed` | `cancelled`). + * + * Trigger values: + * - `scheduled` — fired automatically by the cron/interval scheduler + * - `manual` — triggered by an operator via the admin UI or API + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs` + */ +export const pluginJobRuns = pgTable( + "plugin_job_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the parent job definition. Cascades on delete. */ + jobId: uuid("job_id") + .notNull() + .references(() => pluginJobs.id, { onDelete: "cascade" }), + /** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** What caused this run to start (`"scheduled"` or `"manual"`). */ + trigger: text("trigger").$type().notNull(), + /** Current lifecycle state of this run. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock duration in milliseconds. Null until the run finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Ordered list of log lines emitted during this run. */ + logs: jsonb("logs").$type().notNull().default([]), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + jobIdx: index("plugin_job_runs_job_idx").on(table.jobId), + pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_job_runs_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/plugin_logs.ts b/packages/db/src/schema/plugin_logs.ts new file mode 100644 index 00000000..d32908f1 --- /dev/null +++ b/packages/db/src/schema/plugin_logs.ts @@ -0,0 +1,43 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_logs` table — structured log storage for plugin workers. + * + * Each row stores a single log entry emitted by a plugin worker via + * `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and + * time range to support the operator logs panel and debugging workflows. + * + * Rows are inserted by the host when handling `log` notifications from + * the worker process. A capped retention policy can be applied via + * periodic cleanup (e.g. delete rows older than 7 days). + * + * @see PLUGIN_SPEC.md §26 — Observability + */ +export const pluginLogs = pgTable( + "plugin_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + level: text("level").notNull().default("info"), + message: text("message").notNull(), + meta: jsonb("meta").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginTimeIdx: index("plugin_logs_plugin_time_idx").on( + table.pluginId, + table.createdAt, + ), + levelIdx: index("plugin_logs_level_idx").on(table.level), + }), +); diff --git a/packages/db/src/schema/plugin_state.ts b/packages/db/src/schema/plugin_state.ts new file mode 100644 index 00000000..600797fa --- /dev/null +++ b/packages/db/src/schema/plugin_state.ts @@ -0,0 +1,90 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + unique, +} from "drizzle-orm/pg-core"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_state` table — scoped key-value storage for plugin workers. + * + * Each row stores a single JSON value identified by + * `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use + * this table through `ctx.state.get()`, `ctx.state.set()`, and + * `ctx.state.delete()` in the SDK. + * + * Scope kinds determine the granularity of isolation: + * - `instance` — one value shared across the whole Paperclip instance + * - `company` — one value per company + * - `project` — one value per project + * - `project_workspace` — one value per project workspace + * - `agent` — one value per agent + * - `issue` — one value per issue + * - `goal` — one value per goal + * - `run` — one value per agent run + * + * The `namespace` column defaults to `"default"` and can be used to + * logically group keys without polluting the root namespace. + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` + */ +export const pluginState = pgTable( + "plugin_state", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */ + scopeKind: text("scope_kind").$type().notNull(), + /** + * UUID or text identifier for the scoped object. + * Null for `instance` scope (which has no associated entity). + */ + scopeId: text("scope_id"), + /** + * Sub-namespace to avoid key collisions within a scope. + * Defaults to `"default"` if the plugin does not specify one. + */ + namespace: text("namespace").notNull().default("default"), + /** The key identifying this state entry within the namespace. */ + stateKey: text("state_key").notNull(), + /** JSON-serializable value stored by the plugin. */ + valueJson: jsonb("value_json").notNull(), + /** Timestamp of the most recent write. */ + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + /** + * Unique constraint enforces that there is at most one value per + * (plugin, scope kind, scope id, namespace, key) tuple. + * + * `nullsNotDistinct()` is required so that `scope_id IS NULL` entries + * (used by `instance` scope) are treated as equal by PostgreSQL rather + * than as distinct nulls — otherwise the upsert target in `set()` would + * fail to match existing rows and create duplicates. + * + * Requires PostgreSQL 15+. + */ + uniqueEntry: unique("plugin_state_unique_entry_idx") + .on( + table.pluginId, + table.scopeKind, + table.scopeId, + table.namespace, + table.stateKey, + ) + .nullsNotDistinct(), + /** Speed up lookups by plugin + scope kind (most common access pattern). */ + pluginScopeIdx: index("plugin_state_plugin_scope_idx").on( + table.pluginId, + table.scopeKind, + ), + }), +); diff --git a/packages/db/src/schema/plugin_webhooks.ts b/packages/db/src/schema/plugin_webhooks.ts new file mode 100644 index 00000000..0580e970 --- /dev/null +++ b/packages/db/src/schema/plugin_webhooks.ts @@ -0,0 +1,65 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared"; + +/** + * `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins. + * + * When an external system sends an HTTP POST to a plugin's registered webhook + * endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server + * creates a row in this table before dispatching the payload to the plugin + * worker. This provides an auditable log of every delivery attempt. + * + * The `webhook_key` matches the key declared in the plugin manifest's + * `webhooks` array. `external_id` is an optional identifier supplied by the + * remote system (e.g. a GitHub delivery GUID) that can be used to detect + * and reject duplicate deliveries. + * + * Status values: + * - `pending` — received but not yet dispatched to the worker + * - `processing` — currently being handled by the plugin worker + * - `succeeded` — worker processed the payload successfully + * - `failed` — worker returned an error or timed out + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries` + */ +export const pluginWebhookDeliveries = pgTable( + "plugin_webhook_deliveries", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `webhooks` array. */ + webhookKey: text("webhook_key").notNull(), + /** Optional de-duplication ID provided by the external system. */ + externalId: text("external_id"), + /** Current delivery state. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock processing duration in milliseconds. Null until delivery finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Raw JSON body of the inbound HTTP request. */ + payload: jsonb("payload").$type>().notNull(), + /** Relevant HTTP headers from the inbound request (e.g. signature headers). */ + headers: jsonb("headers").$type>().notNull().default({}), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status), + keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey), + }), +); diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts new file mode 100644 index 00000000..948e5d60 --- /dev/null +++ b/packages/db/src/schema/plugins.ts @@ -0,0 +1,45 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared"; + +/** + * `plugins` table — stores one row per installed plugin. + * + * Each plugin is uniquely identified by `plugin_key` (derived from + * the manifest `id`). The full manifest is persisted as JSONB in + * `manifest_json` so the host can reconstruct capability and UI + * slot information without loading the plugin package. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const plugins = pgTable( + "plugins", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginKey: text("plugin_key").notNull(), + packageName: text("package_name").notNull(), + version: text("version").notNull(), + apiVersion: integer("api_version").notNull().default(1), + categories: jsonb("categories").$type().notNull().default([]), + manifestJson: jsonb("manifest_json").$type().notNull(), + status: text("status").$type().notNull().default("installed"), + installOrder: integer("install_order"), + /** Resolved package path for local-path installs; used to find worker entrypoint. */ + packagePath: text("package_path"), + lastError: text("last_error"), + installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey), + statusIdx: index("plugins_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index d532a0f5..46d368e3 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { goals } from "./goals.js"; import { agents } from "./agents.js"; @@ -15,6 +15,7 @@ export const projects = pgTable( leadAgentId: uuid("lead_agent_id").references(() => agents.id), targetDate: date("target_date"), color: text("color"), + executionWorkspacePolicy: jsonb("execution_workspace_policy").$type>(), archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/db/src/schema/workspace_runtime_services.ts b/packages/db/src/schema/workspace_runtime_services.ts new file mode 100644 index 00000000..0837855f --- /dev/null +++ b/packages/db/src/schema/workspace_runtime_services.ts @@ -0,0 +1,64 @@ +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { projects } from "./projects.js"; +import { projectWorkspaces } from "./project_workspaces.js"; +import { issues } from "./issues.js"; +import { agents } from "./agents.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; + +export const workspaceRuntimeServices = pgTable( + "workspace_runtime_services", + { + id: uuid("id").primaryKey(), + companyId: uuid("company_id").notNull().references(() => companies.id), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), + issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), + scopeType: text("scope_type").notNull(), + scopeId: text("scope_id"), + serviceName: text("service_name").notNull(), + status: text("status").notNull(), + lifecycle: text("lifecycle").notNull(), + reuseKey: text("reuse_key"), + command: text("command"), + cwd: text("cwd"), + port: integer("port"), + url: text("url"), + provider: text("provider").notNull(), + providerRef: text("provider_ref"), + ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), + startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(), + startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), + stoppedAt: timestamp("stopped_at", { withTimezone: true }), + stopPolicy: jsonb("stop_policy").$type>(), + healthStatus: text("health_status").notNull().default("unknown"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on( + table.companyId, + table.projectWorkspaceId, + table.status, + ), + companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on( + table.companyId, + table.projectId, + table.status, + ), + runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId), + companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on( + table.companyId, + table.updatedAt, + ), + }), +); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index a086b149..5a24989c 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md new file mode 100644 index 00000000..24294122 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -0,0 +1,52 @@ +# @paperclipai/create-paperclip-plugin + +Scaffolding tool for creating new Paperclip plugins. + +```bash +npx @paperclipai/create-paperclip-plugin my-plugin +``` + +Or with options: + +```bash +npx @paperclipai/create-paperclip-plugin @acme/my-plugin \ + --template connector \ + --category connector \ + --display-name "Acme Connector" \ + --description "Syncs Acme data into Paperclip" \ + --author "Acme Inc" +``` + +Supported templates: `default`, `connector`, `workspace` +Supported categories: `connector`, `workspace`, `automation`, `ui` + +Generates: +- typed manifest + worker entrypoint +- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks +- test file using `@paperclipai/plugin-sdk/testing` +- `esbuild` and `rollup` config files using SDK bundler presets +- dev server script for hot-reload (`paperclip-plugin-dev-server`) + +The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet. + +Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`. + +Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly: + +```bash +node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \ + --output /absolute/path/to/plugins \ + --sdk-path /absolute/path/to/paperclip/packages/plugins/sdk +``` + +That gives you an outside-repo local development path before the SDK is published to npm. + +## Workflow after scaffolding + +```bash +cd my-plugin +pnpm install +pnpm dev # watch worker + manifest + ui bundles +pnpm dev:ui # local UI preview server with hot-reload events +pnpm test +``` diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json new file mode 100644 index 00000000..e863cd6c --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@paperclipai/create-paperclip-plugin", + "version": "0.1.0", + "type": "module", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts new file mode 100644 index 00000000..d5aec878 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const VALID_TEMPLATES = ["default", "connector", "workspace"] as const; +type PluginTemplate = (typeof VALID_TEMPLATES)[number]; +const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const); + +export interface ScaffoldPluginOptions { + pluginName: string; + outputDir: string; + template?: PluginTemplate; + displayName?: string; + description?: string; + author?: string; + category?: "connector" | "workspace" | "automation" | "ui"; + sdkPath?: string; +} + +/** Validate npm-style plugin package names (scoped or unscoped). */ +export function isValidPluginName(name: string): boolean { + const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/; + const unscopedPattern = /^[a-z0-9._-]+$/; + return scopedPattern.test(name) || unscopedPattern.test(name); +} + +/** Convert `@scope/name` to an output directory basename (`name`). */ +function packageToDirName(pluginName: string): string { + return pluginName.replace(/^@[^/]+\//, ""); +} + +/** Convert an npm package name into a manifest-safe plugin id. */ +function packageToManifestId(pluginName: string): string { + if (!pluginName.startsWith("@")) { + return pluginName; + } + + return pluginName.slice(1).replace("/", "."); +} + +/** Build a human-readable display name from package name tokens. */ +function makeDisplayName(pluginName: string): string { + const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim(); + return raw + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function writeFile(target: string, content: string) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +function formatFileDependency(absPath: string): string { + return `file:${toPosixPath(path.resolve(absPath))}`; +} + +function getLocalSdkPackagePath(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk"); +} + +function getRepoRootFromSdkPath(sdkPath: string): string { + return path.resolve(sdkPath, "..", "..", ".."); +} + +function getLocalSharedPackagePath(sdkPath: string): string { + return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared"); +} + +function isInsideDir(targetPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, targetPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function packLocalPackage(packagePath: string, outputDir: string): string { + const packageJsonPath = path.join(packagePath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Package package.json not found at ${packageJsonPath}`); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + name?: string; + version?: string; + }; + const packageName = packageJson.name ?? path.basename(packagePath); + const packageVersion = packageJson.version ?? "0.0.0"; + const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`; + const sdkBundleDir = path.join(outputDir, ".paperclip-sdk"); + + fs.mkdirSync(sdkBundleDir, { recursive: true }); + execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" }); + execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" }); + + const tarballPath = path.join(sdkBundleDir, tarballFileName); + if (!fs.existsSync(tarballPath)) { + throw new Error(`Packed tarball was not created at ${tarballPath}`); + } + + return tarballPath; +} + +/** + * Generate a complete Paperclip plugin starter project. + * + * Output includes manifest/worker/UI entries, SDK harness tests, bundler presets, + * and a local dev server script for hot-reload workflow. + */ +export function scaffoldPluginProject(options: ScaffoldPluginOptions): string { + const template = options.template ?? "default"; + if (!VALID_TEMPLATES.includes(template)) { + throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`); + } + + if (!isValidPluginName(options.pluginName)) { + throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens."); + } + + if (options.category && !VALID_CATEGORIES.has(options.category)) { + throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`); + } + + const outputDir = path.resolve(options.outputDir); + if (fs.existsSync(outputDir)) { + throw new Error(`Directory already exists: ${outputDir}`); + } + + const displayName = options.displayName ?? makeDisplayName(options.pluginName); + const description = options.description ?? "A Paperclip plugin"; + const author = options.author ?? "Plugin Author"; + const category = options.category ?? (template === "workspace" ? "workspace" : "connector"); + const manifestId = packageToManifestId(options.pluginName); + const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath()); + const localSharedPath = getLocalSharedPackagePath(localSdkPath); + const repoRoot = getRepoRootFromSdkPath(localSdkPath); + const useWorkspaceSdk = isInsideDir(outputDir, repoRoot); + + fs.mkdirSync(outputDir, { recursive: true }); + + const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir); + const sdkDependency = useWorkspaceSdk + ? "workspace:*" + : `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`; + + const packageJson = { + name: options.pluginName, + version: "0.1.0", + type: "module", + private: true, + description, + scripts: { + build: "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + dev: "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + test: "vitest run --config ./vitest.config.ts", + typecheck: "tsc --noEmit" + }, + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui/" + }, + keywords: ["paperclip", "plugin", category], + author, + license: "MIT", + ...(packedSharedTarball + ? { + pnpm: { + overrides: { + "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, + }, + }, + } + : {}), + devDependencies: { + ...(packedSharedTarball + ? { + "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, + } + : {}), + "@paperclipai/plugin-sdk": sdkDependency, + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + esbuild: "^0.27.3", + rollup: "^4.38.0", + tslib: "^2.8.1", + typescript: "^5.7.3", + vitest: "^3.0.5" + }, + peerDependencies: { + react: ">=18" + } + }; + + writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + + const tsconfig = { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["ES2022", "DOM"], + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + declaration: true, + declarationMap: true, + sourceMap: true, + outDir: "dist", + rootDir: "." + }, + include: ["src", "tests"], + exclude: ["dist", "node_modules"] + }; + + writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + + writeFile( + path.join(outputDir, "esbuild.config.mjs"), + `import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} +`, + ); + + writeFile( + path.join(outputDir, "rollup.config.mjs"), + `import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); +`, + ); + + writeFile( + path.join(outputDir, "vitest.config.ts"), + `import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); +`, + ); + + writeFile( + path.join(outputDir, "src", "manifest.ts"), + `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: ${quote(manifestId)}, + apiVersion: 1, + version: "0.1.0", + displayName: ${quote(displayName)}, + description: ${quote(description)}, + author: ${quote(author)}, + categories: [${quote(category)}], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "health-widget", + displayName: ${quote(`${displayName} Health`)}, + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; +`, + ); + + writeFile( + path.join(outputDir, "src", "worker.ts"), + `import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); +`, + ); + + writeFile( + path.join(outputDir, "src", "ui", "index.tsx"), + `import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ ${displayName} +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} +`, + ); + + writeFile( + path.join(outputDir, "tests", "plugin.spec.ts"), + `import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("plugin scaffold", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); +`, + ); + + writeFile( + path.join(outputDir, "README.md"), + `# ${displayName} + +${description} + +## Development + +\`\`\`bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +\`\`\` + +${sdkDependency.startsWith("file:") + ? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n` + : ""} + +## Install Into Paperclip + +\`\`\`bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \\ + -H "Content-Type: application/json" \\ + -d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}' +\`\`\` + +## Build Options + +- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`. +- \`pnpm build:rollup\` uses rollup presets from the same SDK. +`, + ); + + writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n"); + + return outputDir; +} + +function parseArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +/** CLI wrapper for `scaffoldPluginProject`. */ +function runCli() { + const pluginName = process.argv[2]; + if (!pluginName) { + // eslint-disable-next-line no-console + console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ] [--sdk-path ]"); + process.exit(1); + } + + const template = (parseArg("--template") ?? "default") as PluginTemplate; + const outputRoot = parseArg("--output") ?? process.cwd(); + const targetDir = path.resolve(outputRoot, packageToDirName(pluginName)); + + const out = scaffoldPluginProject({ + pluginName, + outputDir: targetDir, + template, + displayName: parseArg("--display-name"), + description: parseArg("--description"), + author: parseArg("--author"), + category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined, + sdkPath: parseArg("--sdk-path"), + }); + + // eslint-disable-next-line no-console + console.log(`Created plugin scaffold at ${out}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli(); +} diff --git a/packages/plugins/create-paperclip-plugin/tsconfig.json b/packages/plugins/create-paperclip-plugin/tsconfig.json new file mode 100644 index 00000000..90314411 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/README.md b/packages/plugins/examples/plugin-authoring-smoke-example/README.md new file mode 100644 index 00000000..50099ad4 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/README.md @@ -0,0 +1,23 @@ +# Plugin Authoring Smoke Example + +A Paperclip plugin + +## Development + +```bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +``` + +## Install Into Paperclip + +```bash +pnpm paperclipai plugin install ./ +``` + +## Build Options + +- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`. +- `pnpm build:rollup` uses rollup presets from the same SDK. diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs new file mode 100644 index 00000000..b5cfd36e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json new file mode 100644 index 00000000..66657e4a --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -0,0 +1,45 @@ +{ + "name": "@paperclipai/plugin-authoring-smoke-example", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "A Paperclip plugin", + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "connector" + ], + "author": "Plugin Author", + "license": "MIT", + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "esbuild": "^0.27.3", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs new file mode 100644 index 00000000..ccee40a7 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts new file mode 100644 index 00000000..eb1c1efe --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts @@ -0,0 +1,32 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "paperclipai.plugin-authoring-smoke-example", + apiVersion: 1, + version: "0.1.0", + displayName: "Plugin Authoring Smoke Example", + description: "A Paperclip plugin", + author: "Plugin Author", + categories: ["connector"], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "health-widget", + displayName: "Plugin Authoring Smoke Example Health", + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx new file mode 100644 index 00000000..2b0cabeb --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx @@ -0,0 +1,23 @@ +import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ Plugin Authoring Smoke Example +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts new file mode 100644 index 00000000..16ef652e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts @@ -0,0 +1,27 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts new file mode 100644 index 00000000..8dddda88 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("plugin scaffold", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json new file mode 100644 index 00000000..a697519e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts new file mode 100644 index 00000000..649a293e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/README.md b/packages/plugins/examples/plugin-file-browser-example/README.md new file mode 100644 index 00000000..ca02fcf7 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/README.md @@ -0,0 +1,62 @@ +# File Browser Example Plugin + +Example Paperclip plugin that demonstrates: + +- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off. +- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support. + +This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included. + +## Slots + +| Slot | Type | Description | +|---------------------|---------------------|--------------------------------------------------| +| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. | +| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.| + +## Settings + +- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off. +- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown. + +## Capabilities + +- `ui.sidebar.register` — project sidebar item +- `ui.detailTab.register` — project detail tab +- `projects.read` — resolve project +- `project.workspaces.read` — list workspaces and read paths for file access + +## Worker + +- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first). +- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`). +- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`). +- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk. + +## Local Install (Dev) + +From the repo root, build the plugin and install it by local path: + +```bash +pnpm --filter @paperclipai/plugin-file-browser-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example +``` + +To uninstall: + +```bash +pnpm paperclipai plugin uninstall paperclip-file-browser-example --force +``` + +**Local development notes:** + +- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists. +- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host. +- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin. +- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config. + +## Structure + +- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`). +- `src/worker.ts` — data handlers for workspaces, file list, file content. +- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor). diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json new file mode 100644 index 00000000..86c720d4 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -0,0 +1,42 @@ +{ + "name": "@paperclipai/plugin-file-browser-example", + "version": "0.1.0", + "description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc && node ./scripts/build-ui.mjs", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.28.0", + "@lezer/highlight": "^1.2.1", + "@paperclipai/plugin-sdk": "workspace:*", + "codemirror": "^6.0.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "esbuild": "^0.27.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, ".."); + +await esbuild.build({ + entryPoints: [path.join(packageRoot, "src/ui/index.tsx")], + outfile: path.join(packageRoot, "dist/ui/index.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@paperclipai/plugin-sdk/ui", + ], + logLevel: "info", +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/src/index.ts b/packages/plugins/examples/plugin-file-browser-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts new file mode 100644 index 00000000..027c134b --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts @@ -0,0 +1,85 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const PLUGIN_ID = "paperclip-file-browser-example"; +const FILES_SIDEBAR_SLOT_ID = "files-link"; +const FILES_TAB_SLOT_ID = "files-tab"; +const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links"; +const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.2.0", + displayName: "File Browser (Example)", + description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "ui.sidebar.register", + "ui.detailTab.register", + "ui.commentAnnotation.register", + "ui.action.register", + "projects.read", + "project.workspaces.read", + "issue.comments.read", + "plugin.state.read", + ], + instanceConfigSchema: { + type: "object", + properties: { + showFilesInSidebar: { + type: "boolean", + title: "Show Files in Sidebar", + default: false, + description: "Adds the Files link under each project in the sidebar.", + }, + commentAnnotationMode: { + type: "string", + title: "Comment File Links", + enum: ["annotation", "contextMenu", "both", "none"], + default: "both", + description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.", + }, + }, + }, + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "projectSidebarItem", + id: FILES_SIDEBAR_SLOT_ID, + displayName: "Files", + exportName: "FilesLink", + entityTypes: ["project"], + order: 10, + }, + { + type: "detailTab", + id: FILES_TAB_SLOT_ID, + displayName: "Files", + exportName: "FilesTab", + entityTypes: ["project"], + order: 10, + }, + { + type: "commentAnnotation", + id: COMMENT_FILE_LINKS_SLOT_ID, + displayName: "File Links", + exportName: "CommentFileLinks", + entityTypes: ["comment"], + }, + { + type: "commentContextMenuItem", + id: COMMENT_OPEN_FILES_SLOT_ID, + displayName: "Open in Files", + exportName: "CommentOpenFiles", + entityTypes: ["comment"], + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx new file mode 100644 index 00000000..0e12d903 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -0,0 +1,815 @@ +import type { + PluginProjectSidebarItemProps, + PluginDetailTabProps, + PluginCommentAnnotationProps, + PluginCommentContextMenuItemProps, +} from "@paperclipai/plugin-sdk/ui"; +import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui"; +import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react"; +import { EditorView } from "@codemirror/view"; +import { basicSetup } from "codemirror"; +import { javascript } from "@codemirror/lang-javascript"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +const PLUGIN_KEY = "paperclip-file-browser-example"; +const FILES_TAB_SLOT_ID = "files-tab"; + +const editorBaseTheme = { + "&": { + height: "100%", + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace", + fontSize: "13px", + lineHeight: "1.6", + }, + ".cm-content": { + padding: "12px 14px 18px", + }, +}; + +const editorDarkTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "oklch(0.23 0.02 255)", + color: "oklch(0.93 0.01 255)", + }, + ".cm-gutters": { + backgroundColor: "oklch(0.25 0.015 255)", + color: "oklch(0.74 0.015 255)", + borderRight: "1px solid oklch(0.34 0.01 255)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "oklch(0.30 0.012 255 / 0.55)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "oklch(0.42 0.02 255 / 0.45)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "oklch(0.47 0.025 255 / 0.5)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "oklch(0.93 0.01 255)", + }, + ".cm-matchingBracket": { + backgroundColor: "oklch(0.37 0.015 255 / 0.5)", + color: "oklch(0.95 0.01 255)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "oklch(0.70 0.08 24)", + }, +}, { dark: true }); + +const editorLightTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))", + color: "var(--foreground)", + }, + ".cm-content": { + ...editorBaseTheme[".cm-content"], + caretColor: "var(--foreground)", + }, + ".cm-gutters": { + backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))", + color: "var(--muted-foreground)", + borderRight: "1px solid var(--border)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)", + }, + ".cm-matchingBracket": { + backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)", + color: "var(--foreground)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "var(--destructive)", + }, +}); + +const editorDarkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.78 0.025 265)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" }, +]); + +const editorLightHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.45 0.07 270)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" }, +]); + +type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean }; +type FileEntry = { name: string; path: string; isDirectory: boolean }; +type FileTreeNodeProps = { + entry: FileEntry; + companyId: string | null; + projectId: string; + workspaceId: string; + selectedPath: string | null; + onSelect: (path: string) => void; + depth?: number; +}; + +const PathLikePattern = /[\\/]/; +const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/; +const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isLikelyPath(pathValue: string): boolean { + const trimmed = pathValue.trim(); + return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed); +} + +function workspaceLabel(workspace: Workspace): string { + const pathLabel = workspace.path.trim(); + const nameLabel = workspace.name.trim(); + const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel); + const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel); + const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : ""; + if (!baseLabel) { + return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)"; + } + + return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel; +} + +function useIsMobile(breakpointPx = 768): boolean { + const [isMobile, setIsMobile] = useState(() => + typeof window !== "undefined" ? window.innerWidth < breakpointPx : false, + ); + + useEffect(() => { + if (typeof window === "undefined") return; + const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`); + const update = () => setIsMobile(mediaQuery.matches); + update(); + mediaQuery.addEventListener("change", update); + return () => mediaQuery.removeEventListener("change", update); + }, [breakpointPx]); + + return isMobile; +} + +function useIsDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(() => + typeof document !== "undefined" && document.documentElement.classList.contains("dark"), + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + const update = () => setIsDarkMode(root.classList.contains("dark")); + update(); + + const observer = new MutationObserver(update); + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + return isDarkMode; +} + +function useAvailableHeight( + ref: RefObject, + options?: { bottomPadding?: number; minHeight?: number }, +): number | null { + const bottomPadding = options?.bottomPadding ?? 24; + const minHeight = options?.minHeight ?? 384; + const [height, setHeight] = useState(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + const update = () => { + const element = ref.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding)); + setHeight(nextHeight); + }; + + update(); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + + const observer = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => update()) + : null; + if (observer && ref.current) observer.observe(ref.current); + + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + observer?.disconnect(); + }; + }, [bottomPadding, minHeight, ref]); + + return height; +} + +function FileTreeNode({ + entry, + companyId, + projectId, + workspaceId, + selectedPath, + onSelect, + depth = 0, +}: FileTreeNodeProps) { + const [isExpanded, setIsExpanded] = useState(false); + const isSelected = selectedPath === entry.path; + + if (entry.isDirectory) { + return ( +
  • + + {isExpanded ? ( + + ) : null} +
  • + ); + } + + return ( +
  • + +
  • + ); +} + +function ExpandedDirectoryChildren({ + directoryPath, + companyId, + projectId, + workspaceId, + selectedPath, + onSelect, + depth, +}: { + directoryPath: string; + companyId: string | null; + projectId: string; + workspaceId: string; + selectedPath: string | null; + onSelect: (path: string) => void; + depth: number; +}) { + const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", { + companyId, + projectId, + workspaceId, + directoryPath, + }); + const children = childData?.entries ?? []; + + if (children.length === 0) { + return null; + } + + return ( +
      + {children.map((child) => ( + + ))} +
    + ); +} + +/** + * Project sidebar item: link "Files" that opens the project detail with the Files plugin tab. + */ +export function FilesLink({ context }: PluginProjectSidebarItemProps) { + const { data: config, loading: configLoading } = usePluginData("plugin-config", {}); + const showFilesInSidebar = config?.showFilesInSidebar ?? false; + + if (configLoading || !showFilesInSidebar) { + return null; + } + + const projectId = context.entityId; + const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null }) + .projectRef + ?? projectId; + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; + const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`; + const isActive = typeof window !== "undefined" && (() => { + const pathname = window.location.pathname.replace(/\/+$/, ""); + const segments = pathname.split("/").filter(Boolean); + const projectsIndex = segments.indexOf("projects"); + const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null; + const activeTab = new URLSearchParams(window.location.search).get("tab"); + if (activeTab !== tabValue) return false; + if (!activeProjectRef) return false; + return activeProjectRef === projectId || activeProjectRef === projectRef; + })(); + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented + || event.button !== 0 + || event.metaKey + || event.ctrlKey + || event.altKey + || event.shiftKey + ) { + return; + } + + event.preventDefault(); + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + }; + + return ( + + Files + + ); +} + +/** + * Project detail tab: workspace selector, file tree, and CodeMirror editor. + */ +export function FilesTab({ context }: PluginDetailTabProps) { + const companyId = context.companyId; + const projectId = context.entityId; + const isMobile = useIsMobile(); + const isDarkMode = useIsDarkMode(); + const panesRef = useRef(null); + const availableHeight = useAvailableHeight(panesRef, { + bottomPadding: isMobile ? 16 : 24, + minHeight: isMobile ? 320 : 420, + }); + const { data: workspacesData } = usePluginData("workspaces", { + projectId, + companyId, + }); + const workspaces = workspacesData ?? []; + const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); + const [workspaceId, setWorkspaceId] = useState(null); + const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null; + const selectedWorkspace = useMemo( + () => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null, + [workspaces, resolvedWorkspaceId], + ); + + const fileListParams = useMemo( + () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}), + [companyId, projectId, selectedWorkspace], + ); + const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>( + "fileList", + fileListParams, + ); + const entries = fileListData?.entries ?? []; + + // Track the `?file=` query parameter across navigations (popstate). + const [urlFilePath, setUrlFilePath] = useState(() => { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("file") || null; + }); + const lastConsumedFileRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + const onNav = () => { + const next = new URLSearchParams(window.location.search).get("file") || null; + setUrlFilePath(next); + }; + window.addEventListener("popstate", onNav); + return () => window.removeEventListener("popstate", onNav); + }, []); + + const [selectedPath, setSelectedPath] = useState(null); + useEffect(() => { + setSelectedPath(null); + setMobileView("browser"); + lastConsumedFileRef.current = null; + }, [selectedWorkspace?.id]); + + // When a file path appears (or changes) in the URL and workspace is ready, select it. + useEffect(() => { + if (!urlFilePath || !selectedWorkspace) return; + if (lastConsumedFileRef.current === urlFilePath) return; + lastConsumedFileRef.current = urlFilePath; + setSelectedPath(urlFilePath); + setMobileView("editor"); + }, [urlFilePath, selectedWorkspace]); + + const fileContentParams = useMemo( + () => + selectedPath && selectedWorkspace + ? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath } + : null, + [companyId, projectId, selectedWorkspace, selectedPath], + ); + const fileContentResult = usePluginData<{ content: string | null; error?: string }>( + "fileContent", + fileContentParams ?? {}, + ); + const { data: fileContentData, refresh: refreshFileContent } = fileContentResult; + const writeFile = usePluginAction("writeFile"); + const editorRef = useRef(null); + const viewRef = useRef(null); + const loadedContentRef = useRef(""); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + const [saveError, setSaveError] = useState(null); + const [mobileView, setMobileView] = useState<"browser" | "editor">("browser"); + + useEffect(() => { + if (!editorRef.current) return; + const content = fileContentData?.content ?? ""; + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage(null); + setSaveError(null); + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + const view = new EditorView({ + doc: content, + extensions: [ + basicSetup, + javascript(), + isDarkMode ? editorDarkTheme : editorLightTheme, + syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle), + EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + const nextValue = update.state.doc.toString(); + setIsDirty(nextValue !== loadedContentRef.current); + setSaveMessage(null); + setSaveError(null); + }), + ], + parent: editorRef.current, + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + }, [fileContentData?.content, selectedPath, isDarkMode]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") { + return; + } + if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) { + return; + } + event.preventDefault(); + void handleSave(); + }; + + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [selectedWorkspace, selectedPath, isDirty, isSaving]); + + async function handleSave() { + if (!selectedWorkspace || !selectedPath || !viewRef.current) { + return; + } + const content = viewRef.current.state.doc.toString(); + setIsSaving(true); + setSaveError(null); + setSaveMessage(null); + try { + await writeFile({ + projectId, + companyId, + workspaceId: selectedWorkspace.id, + filePath: selectedPath, + content, + }); + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage("Saved"); + refreshFileContent(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setIsSaving(false); + } + } + + return ( +
    +
    + + +
    + +
    +
    +
    + File Tree +
    +
    + {selectedWorkspace ? ( + fileListLoading ? ( +

    Loading files...

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

    No files found in this workspace.

    + ) + ) : ( +

    Select a workspace to browse files.

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