forked from farhoodlabs/paperclip
Merge branch 'master' into fix/clear-extra-args-config
This commit is contained in:
@@ -38,9 +38,25 @@
|
||||
|
||||
-
|
||||
|
||||
## Model Used
|
||||
|
||||
<!--
|
||||
Required. Specify which AI model was used to produce or assist with
|
||||
this change. Be as descriptive as possible — include:
|
||||
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
|
||||
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
|
||||
• Context window size if relevant (e.g., 1M context)
|
||||
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
|
||||
• Any other relevant capability details (e.g., tool use, code execution)
|
||||
If no AI model was used, write "None — human-authored".
|
||||
-->
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have specified the model used (with version and capability details)
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
|
||||
@@ -146,3 +146,44 @@ A change is done when all are true:
|
||||
2. Typecheck, tests, and build pass
|
||||
3. Contracts are synced across db/shared/server/ui
|
||||
4. Docs updated when behavior or commands change
|
||||
|
||||
## 11. Fork-Specific: HenkDz/paperclip
|
||||
|
||||
This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path).
|
||||
- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch.
|
||||
|
||||
### Hermes (plugin only)
|
||||
|
||||
- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded.
|
||||
- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source.
|
||||
- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo.
|
||||
|
||||
### Local Dev
|
||||
|
||||
- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance)
|
||||
- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead
|
||||
- Server startup from NTFS takes 30-60s — don't assume failure immediately
|
||||
- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"`
|
||||
- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite`
|
||||
|
||||
### Fork QoL Patches (not in upstream)
|
||||
|
||||
These are local modifications in the fork's UI. If re-copying source, these must be re-applied:
|
||||
|
||||
1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx`
|
||||
2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser)
|
||||
3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars
|
||||
|
||||
### Plugin System
|
||||
|
||||
PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details.
|
||||
|
||||
- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json`
|
||||
- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading
|
||||
- `createServerAdapter()` must include ALL optional fields (especially `detectModel`)
|
||||
- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing
|
||||
- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm)
|
||||
|
||||
+21
-5
@@ -11,8 +11,9 @@ We really appreciate both small fixes and thoughtful larger changes.
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
- All tests pass and CI is green
|
||||
- Greptile score is 5/5 with all comments addressed
|
||||
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
@@ -26,11 +27,26 @@ These almost always get merged quickly when they're clean.
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
- All tests passing and CI green
|
||||
- Greptile score 5/5 with all comments addressed
|
||||
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## PR Requirements (all PRs)
|
||||
|
||||
### Use the PR Template
|
||||
|
||||
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, and a Checklist.
|
||||
|
||||
### Tests Must Pass
|
||||
|
||||
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
|
||||
|
||||
### Greptile Review
|
||||
|
||||
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
||||
|
||||
## General Rules (both paths)
|
||||
|
||||
- Write clear commit messages
|
||||
@@ -41,7 +57,7 @@ PRs that follow this path are **much** more likely to be accepted, even when the
|
||||
|
||||
## Writing a Good PR message
|
||||
|
||||
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
|
||||
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
|
||||
|
||||
### Thinking Path Example 1:
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
- Created branch: feat/external-adapter-phase1
|
||||
|
||||
I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front.
|
||||
|
||||
What I changed
|
||||
|
||||
1. Server adapter registry is now mutable
|
||||
Files:
|
||||
- server/src/adapters/registry.ts
|
||||
- server/src/adapters/index.ts
|
||||
|
||||
Added:
|
||||
- registerServerAdapter(adapter)
|
||||
- unregisterServerAdapter(type)
|
||||
- requireServerAdapter(type)
|
||||
|
||||
Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup.
|
||||
|
||||
Why this is merge-friendly:
|
||||
- existing built-in adapter definitions stay where they already are
|
||||
- existing lookup helpers still exist
|
||||
- no big architectural rewrite yet
|
||||
|
||||
1. Runtime adapter validation moved to server routes
|
||||
File:
|
||||
- server/src/routes/agents.ts
|
||||
|
||||
Added:
|
||||
- assertKnownAdapterType(...)
|
||||
|
||||
Used it in:
|
||||
- /companies/:companyId/adapters/:type/models
|
||||
- /companies/:companyId/adapters/:type/detect-model
|
||||
- /companies/:companyId/adapters/:type/test-environment
|
||||
- POST /companies/:companyId/agents
|
||||
- POST /companies/:companyId/agent-hires
|
||||
- PATCH /agents/:id when adapterType is touched
|
||||
|
||||
Why:
|
||||
- shared schemas can now allow external adapter strings
|
||||
- server becomes the real source of truth for “is this adapter actually registered?”
|
||||
|
||||
1. Shared adapterType validation is now open-ended for inputs
|
||||
Files:
|
||||
- packages/shared/src/adapter-type.ts
|
||||
- packages/shared/src/validators/agent.ts
|
||||
- packages/shared/src/validators/access.ts
|
||||
- packages/shared/src/index.ts
|
||||
|
||||
Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to:
|
||||
- agentAdapterTypeSchema
|
||||
- optionalAgentAdapterTypeSchema
|
||||
|
||||
These accept any non-empty string.
|
||||
|
||||
Important:
|
||||
- I did not remove AGENT_ADAPTER_TYPES from constants
|
||||
- I did not change the built-in type unions yet
|
||||
- this keeps the change minimal and limits blast radius
|
||||
|
||||
So:
|
||||
- input payloads can carry external adapter types
|
||||
- actual acceptance still depends on server registry validation
|
||||
|
||||
1. UI adapter registry is now mutable too
|
||||
Files:
|
||||
- ui/src/adapters/registry.ts
|
||||
- ui/src/adapters/index.ts
|
||||
|
||||
Added:
|
||||
- registerUIAdapter(adapter)
|
||||
- unregisterUIAdapter(type)
|
||||
- findUIAdapter(type)
|
||||
|
||||
Built-ins are still registered from the same file, same pattern as server.
|
||||
|
||||
1. Began switching UI adapter lists toward the registry
|
||||
Files:
|
||||
- ui/src/adapters/metadata.ts
|
||||
- ui/src/components/AgentConfigForm.tsx
|
||||
- ui/src/pages/NewAgent.tsx
|
||||
|
||||
Changed:
|
||||
- AgentConfigForm adapter list now comes from registered UI adapters via metadata helper
|
||||
- NewAgent supported adapter set now derives from listUIAdapters()
|
||||
|
||||
Why:
|
||||
- phase 1 should start reducing hardcoded UI lists
|
||||
- but without rewriting every page at once
|
||||
|
||||
1. Tests added first, then implementation
|
||||
New tests:
|
||||
- server/src/__tests__/adapter-registry.test.ts
|
||||
- server/src/__tests__/agent-adapter-validation-routes.test.ts
|
||||
- ui/src/adapters/registry.test.ts
|
||||
- packages/shared/src/adapter-types.test.ts
|
||||
|
||||
Small test mock fix:
|
||||
- server/src/__tests__/agent-skills-routes.test.ts
|
||||
|
||||
Current status
|
||||
|
||||
The branch is created and the phase-1 code is started.
|
||||
|
||||
I also followed TDD in spirit:
|
||||
- wrote failing tests first for missing register/unregister APIs
|
||||
- then implemented the registry changes
|
||||
|
||||
What is not finished yet
|
||||
|
||||
I have not finished the green/stabilization pass yet.
|
||||
|
||||
I still need to:
|
||||
- run the targeted tests again after the implementation changes
|
||||
- fix any TS / mocking fallout
|
||||
- decide how far to take phase-1 UI wiring in this branch before stopping
|
||||
|
||||
Also, I intentionally did not yet touch the bigger hardcoded surfaces like:
|
||||
- NewAgentDialog
|
||||
- OnboardingWizard
|
||||
- InviteLanding
|
||||
- duplicated adapterLabels maps
|
||||
- plugin manifest / plugin loader adapter contributions
|
||||
|
||||
That is deliberate.
|
||||
For merge safety, I started with the smallest useful seam:
|
||||
- dynamic server registry
|
||||
- dynamic UI registry
|
||||
- runtime validation based on registry
|
||||
- first couple of UI consumers
|
||||
|
||||
One note
|
||||
There was already an unrelated dirty change in:
|
||||
- server/scripts/dev-watch.ts
|
||||
|
||||
I left that alone and did not make phase-1 changes there.
|
||||
|
||||
Recommended next move
|
||||
I should keep going on this same branch and do the stabilization pass:
|
||||
1. rerun server/ui/shared targeted tests
|
||||
2. fix failures
|
||||
3. run typechecks
|
||||
4. then extend the same registry-driven approach to the next safest UI surfaces
|
||||
@@ -175,6 +175,8 @@ Seed modes:
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
|
||||
|
||||
That repo-local env also sets:
|
||||
|
||||
- `PAPERCLIP_IN_WORKTREE=true`
|
||||
|
||||
@@ -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": 300,
|
||||
"maxTurnsPerRun": 1000,
|
||||
"dangerouslySkipPermissions": true,
|
||||
"env": {"KEY": "VALUE"},
|
||||
"extraArgs": [],
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
title: Adapter UI Parser Contract
|
||||
summary: Ship a custom run-log parser so the Paperclip UI renders your adapter's output correctly
|
||||
---
|
||||
|
||||
When Paperclip runs an agent, stdout is streamed to the UI in real time. The UI needs a **parser** to convert raw stdout lines into structured transcript entries (tool calls, tool results, assistant messages, system events). Without a custom parser, the UI falls back to a generic shell parser that treats every non-system line as `assistant` output — tool commands leak as plain text, durations are lost, and errors are invisible.
|
||||
|
||||
## The Problem
|
||||
|
||||
Most agent CLIs emit structured stdout with tool calls, progress indicators, and multi-line output. For example:
|
||||
|
||||
```
|
||||
[hermes] Session resumed: abc123
|
||||
┊ 💬 Thinking about how to approach this...
|
||||
┊ $ ls /home/user/project
|
||||
┊ [done] $ ls /home/user/project — /src /README.md 0.3s
|
||||
┊ 💬 I see the project structure. Let me read the README.
|
||||
┊ read /home/user/project/README.md
|
||||
┊ [done] read — Project Overview: A CLI tool for... 1.2s
|
||||
The project is a CLI tool. Here's what I found:
|
||||
- It uses TypeScript
|
||||
- Tests are in /tests
|
||||
```
|
||||
|
||||
Without a parser, the UI shows all of this as raw `assistant` text — the tool calls and results are indistinguishable from the agent's actual response.
|
||||
|
||||
With a parser, the UI renders:
|
||||
|
||||
- `Thinking about how to approach this...` as a collapsible thinking block
|
||||
- `$ ls /home/user/project` as a tool call card (collapsed)
|
||||
- `0.3s` duration as a tool result card
|
||||
- `The project is a CLI tool...` as the assistant's response
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌──────────────────┐ package.json ┌──────────────────┐
|
||||
│ Adapter Package │─── exports["./ui-parser"] ──→│ dist/ui-parser.js │
|
||||
│ (npm / local) │ │ (zero imports) │
|
||||
└──────────────────┘ └────────┬─────────┘
|
||||
│ plugin-loader reads at startup
|
||||
▼
|
||||
┌──────────────────┐ GET /api/:type/ui-parser.js ┌──────────────────┐
|
||||
│ Paperclip Server │◄────────────────────────────────│ uiParserCache │
|
||||
│ (in-memory) │ └──────────────────┘
|
||||
└────────┬─────────┘
|
||||
│ serves JS to browser
|
||||
▼
|
||||
┌──────────────────┐ fetch() + eval ┌──────────────────┐
|
||||
│ Paperclip UI │─────────────────────→│ parseStdoutLine │
|
||||
│ (dynamic loader) │ registers parser │ (per-adapter) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
1. **Build time** — You compile `src/ui-parser.ts` to `dist/ui-parser.js` (zero runtime imports)
|
||||
2. **Server startup** — Plugin loader reads the file and caches it in memory
|
||||
3. **UI load** — When the user opens a run, the UI fetches the parser from `GET /api/:type/ui-parser.js`
|
||||
4. **Runtime** — The fetched module is eval'd and registered. All subsequent lines use the real parser
|
||||
|
||||
## Contract: package.json
|
||||
|
||||
### 1. `paperclip.adapterUiParser` — contract version
|
||||
|
||||
```json
|
||||
{
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Paperclip host checks this field. If the major version is unsupported, the host logs a warning and falls back to the generic parser instead of executing potentially incompatible code.
|
||||
|
||||
| Host expects | Adapter declares | Result |
|
||||
|---|---|---|
|
||||
| `1.x` | `1.0.0` | Parser loaded |
|
||||
| `1.x` | `2.0.0` | Warning logged, generic parser used |
|
||||
| `1.x` | (missing) | Parser loaded (grace period — future versions may require it) |
|
||||
|
||||
### 2. `exports["./ui-parser"]` — file path
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contract: Module Exports
|
||||
|
||||
Your `dist/ui-parser.js` must export **at least one** of:
|
||||
|
||||
### `parseStdoutLine(line: string, ts: string): TranscriptEntry[]`
|
||||
|
||||
Static parser. Called for each line of adapter stdout.
|
||||
|
||||
```ts
|
||||
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
if (line.startsWith("[my-agent]")) {
|
||||
return [{ kind: "system", ts, text: line }];
|
||||
}
|
||||
return [{ kind: "assistant", ts, text: line }];
|
||||
}
|
||||
```
|
||||
|
||||
### `createStdoutParser(): { parseLine(line, ts): TranscriptEntry[]; reset(): void }`
|
||||
|
||||
Stateful parser factory. Preferred if your parser needs to track multi-line continuation, command nesting, or other cross-call state.
|
||||
|
||||
```ts
|
||||
let counter = 0;
|
||||
|
||||
export function createStdoutParser() {
|
||||
let suppressContinuation = false;
|
||||
|
||||
function parseLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (suppressContinuation) {
|
||||
if (/^[\d.]+s$/.test(trimmed)) {
|
||||
suppressContinuation = false;
|
||||
return [];
|
||||
}
|
||||
return []; // swallow continuation lines
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[tool-done]")) {
|
||||
const id = `tool-${++counter}`;
|
||||
suppressContinuation = true;
|
||||
return [
|
||||
{ kind: "tool_call", ts, name: "shell", input: {}, toolUseId: id },
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: trimmed, isError: false },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ kind: "assistant", ts, text: trimmed }];
|
||||
}
|
||||
|
||||
function reset() {
|
||||
suppressContinuation = false;
|
||||
}
|
||||
|
||||
return { parseLine, reset };
|
||||
}
|
||||
```
|
||||
|
||||
If both are exported, `createStdoutParser` takes priority.
|
||||
|
||||
## Contract: TranscriptEntry
|
||||
|
||||
Each entry must match one of these discriminated union shapes:
|
||||
|
||||
```ts
|
||||
// Assistant message
|
||||
{ kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
|
||||
// Thinking / reasoning
|
||||
{ kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
|
||||
// User message (rare — usually from agent-initiated prompts)
|
||||
{ kind: "user"; ts: string; text: string }
|
||||
|
||||
// Tool invocation
|
||||
{ kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
|
||||
// Tool result
|
||||
{ kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
|
||||
// System / adapter messages
|
||||
{ kind: "system"; ts: string; text: string }
|
||||
|
||||
// Stderr / errors
|
||||
{ kind: "stderr"; ts: string; text: string }
|
||||
|
||||
// Raw stdout (fallback)
|
||||
{ kind: "stdout"; ts: string; text: string }
|
||||
```
|
||||
|
||||
### Linking tool calls to results
|
||||
|
||||
Use `toolUseId` to pair `tool_call` and `tool_result` entries. The UI renders them as collapsible cards.
|
||||
|
||||
```ts
|
||||
const id = `my-tool-${++counter}`;
|
||||
return [
|
||||
{ kind: "tool_call", ts, name: "read", input: { path: "/src/main.ts" }, toolUseId: id },
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: "const main = () => {...}", isError: false },
|
||||
];
|
||||
```
|
||||
|
||||
### Error handling
|
||||
|
||||
Set `isError: true` on tool results to show a red indicator:
|
||||
|
||||
```ts
|
||||
{ kind: "tool_result", ts, toolUseId: id, content: "ENOENT: no such file", isError: true }
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
1. **Zero runtime imports.** Your file is loaded via `URL.createObjectURL` + dynamic `import()` in the browser. No `import`, no `require`, no top-level `await`.
|
||||
|
||||
2. **No DOM / Node.js APIs.** Runs in a browser sandbox. Use only vanilla JS (ES2020+).
|
||||
|
||||
3. **No side effects.** Module-level code must not modify globals, access `window`, or perform I/O. Only declare and export functions.
|
||||
|
||||
4. **Deterministic.** Given the same `(line, ts)` input, the same output must be produced. This matters for log replay.
|
||||
|
||||
5. **Error-tolerant.** Never throw. Return `[{ kind: "stdout", ts, text: line }]` for any line you can't parse, rather than crashing the transcript.
|
||||
|
||||
6. **File size.** Keep under 50 KB. This is served per-request and eval'd in the browser.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Event | What happens |
|
||||
|---|---|
|
||||
| Server starts | Plugin loader reads `exports["./ui-parser"]`, reads the file, caches in memory |
|
||||
| UI opens run | `getUIAdapter(type)` called. If no built-in parser, kicks off async `fetch(/api/:type/ui-parser.js)` |
|
||||
| First lines arrive | Generic process parser handles them immediately (no blocking). Dynamic parser loads in background |
|
||||
| Parser loads | `registerUIAdapter()` called. All subsequent line parsing uses the real parser |
|
||||
| Parser fails (404, eval error) | Warning logged to console. Generic parser continues. Failed type is cached — no retries |
|
||||
| Server restart | In-memory cache is repopulated from adapter packages |
|
||||
|
||||
## Error Behavior
|
||||
|
||||
| Failure | What happens |
|
||||
|---|---|
|
||||
| Module syntax error (import fails) | Caught, logged, falls back to generic parser. No retries. |
|
||||
| Returns wrong shape | Individual entries with missing fields are silently ignored by the transcript builder. |
|
||||
| Throws at runtime | Caught per-line. That line falls back to generic. Parser stays registered for future lines. |
|
||||
| 404 (no ui-parser export) | Type added to failed-loads set. Generic parser from first call onward. |
|
||||
| Contract version mismatch | Server logs warning, skips loading. Generic parser used. |
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
# Compile TypeScript to JavaScript
|
||||
tsc src/ui-parser.ts --outDir dist --target ES2020 --module ES2020 --declaration false
|
||||
```
|
||||
|
||||
Your `tsconfig.json` can handle this automatically — just make sure `ui-parser.ts` is included in the build and outputs to `dist/ui-parser.js`.
|
||||
|
||||
## Testing
|
||||
|
||||
Test your parser locally by running it against sample stdout:
|
||||
|
||||
```ts
|
||||
// test-parser.ts
|
||||
import { createStdoutParser } from "./dist/ui-parser.js";
|
||||
|
||||
const parser = createStdoutParser();
|
||||
const sampleLines = [
|
||||
"[my-agent] Starting session abc123",
|
||||
"Thinking about the task...",
|
||||
"$ ls /home/user/project",
|
||||
"[done] $ ls — /src /README.md 0.3s",
|
||||
"I'll read the README now.",
|
||||
"Error: file not found",
|
||||
];
|
||||
|
||||
for (const line of sampleLines) {
|
||||
const entries = parser.parseLine(line, new Date().toISOString());
|
||||
for (const entry of entries) {
|
||||
console.log(` ${entry.kind}:`, entry.text ?? entry.name ?? entry.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run with: `npx tsx test-parser.ts`
|
||||
|
||||
## Skipping the UI Parser
|
||||
|
||||
If your adapter's stdout is simple (no tool markers, no special formatting), you can skip the UI parser entirely. The generic `process` parser will handle it — every non-system line becomes `assistant` output. This is fine for:
|
||||
|
||||
- Agents that output plain text responses
|
||||
- Custom scripts that just print results
|
||||
- Simple CLIs without structured output
|
||||
|
||||
To skip it, simply don't include `exports["./ui-parser"]` in your `package.json`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [External Adapters](/adapters/external-adapters) — full guide to building adapter packages
|
||||
- [Creating an Adapter](/adapters/creating-an-adapter) — adapter internals and built-in integration
|
||||
@@ -21,7 +21,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible |
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
|
||||
@@ -9,23 +9,40 @@ Build a custom adapter to connect Paperclip to any agent runtime.
|
||||
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.
|
||||
</Tip>
|
||||
|
||||
## Two Paths
|
||||
|
||||
| | Built-in | External Plugin |
|
||||
|---|---|---|
|
||||
| Source | Inside `paperclip-fork` | Separate npm package |
|
||||
| Distribution | Ships with Paperclip | Independent npm publish |
|
||||
| UI parser | Static import | Dynamic load from API |
|
||||
| Registration | Edit 3 registries | Auto-loaded at startup |
|
||||
| Best for | Core adapters, contributors | Third-party adapters, internal tools |
|
||||
|
||||
For most cases, **build an external adapter plugin**. It's cleaner, independently versioned, and doesn't require modifying Paperclip's source. See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
The rest of this page covers the shared internals that both paths use.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/adapters/<name>/
|
||||
packages/adapters/<name>/ # built-in
|
||||
── or ──
|
||||
my-adapter/ # external plugin
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata
|
||||
server/
|
||||
index.ts # Server exports
|
||||
index.ts # Server exports (createServerAdapter)
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui/
|
||||
index.ts # UI exports
|
||||
parse-stdout.ts # Transcript parser
|
||||
index.ts # UI exports (built-in only)
|
||||
parse-stdout.ts # Transcript parser (built-in only)
|
||||
build-config.ts # Config builder
|
||||
ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract](/adapters/adapter-ui-parser))
|
||||
cli/
|
||||
index.ts # CLI exports
|
||||
format-event.ts # Terminal formatter
|
||||
@@ -46,6 +63,9 @@ Use when: ...
|
||||
Don't use when: ...
|
||||
Core fields: ...
|
||||
`;
|
||||
|
||||
// Required for external adapters (plugin-loader convention)
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
## Step 2: Server Execute
|
||||
@@ -54,7 +74,7 @@ Core fields: ...
|
||||
|
||||
Key responsibilities:
|
||||
|
||||
1. Read config using safe helpers (`asString`, `asNumber`, etc.)
|
||||
1. Read config using safe helpers (`asString`, `asNumber`, etc.) from `@paperclipai/adapter-utils/server-utils`
|
||||
2. Build environment with `buildPaperclipEnv(agent)` plus context vars
|
||||
3. Resolve session state from `runtime.sessionParams`
|
||||
4. Render prompt with `renderTemplate(template, data)`
|
||||
@@ -62,27 +82,102 @@ Key responsibilities:
|
||||
6. Parse output for usage, costs, session state, errors
|
||||
7. Handle unknown session errors (retry fresh, set `clearSession: true`)
|
||||
|
||||
### Available Helpers
|
||||
|
||||
| Helper | Source | Purpose |
|
||||
|--------|--------|---------|
|
||||
| `runChildProcess(cmd, opts)` | `@paperclipai/adapter-utils/server-utils` | Spawn with timeout, grace, streaming |
|
||||
| `buildPaperclipEnv(agent)` | `@paperclipai/adapter-utils/server-utils` | Inject `PAPERCLIP_*` env vars |
|
||||
| `renderTemplate(tpl, data)` | `@paperclipai/adapter-utils/server-utils` | `{{variable}}` substitution |
|
||||
| `asString(v)` | `@paperclipai/adapter-utils` | Safe config value extraction |
|
||||
| `asNumber(v)` | `@paperclipai/adapter-utils` | Safe number extraction |
|
||||
|
||||
### AdapterExecutionContext
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionContext {
|
||||
runId: string;
|
||||
agent: { id: string; companyId: string; name: string; adapterConfig: unknown };
|
||||
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
|
||||
config: Record<string, unknown>; // agent's adapterConfig
|
||||
context: Record<string, unknown>; // task, wake reason, etc.
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### AdapterExecutionResult
|
||||
|
||||
```ts
|
||||
interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
usage?: { inputTokens: number; outputTokens: number };
|
||||
sessionParams?: Record<string, unknown> | null; // persist across heartbeats
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
costUsd?: number | null;
|
||||
clearSession?: boolean; // set true to force fresh session on next wake
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Environment Test
|
||||
|
||||
`src/server/test.ts` validates the adapter config before running.
|
||||
|
||||
Return structured diagnostics:
|
||||
|
||||
- `error` for invalid/unusable setup
|
||||
- `warn` for non-blocking issues
|
||||
- `info` for successful checks
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `error` | Invalid or unusable setup | Blocks execution |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `info` | Successful check | Shown in test results |
|
||||
|
||||
## Step 4: UI Module
|
||||
```ts
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: "pass", // "pass" | "warn" | "fail"
|
||||
checks: [
|
||||
{ level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" },
|
||||
{ level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" },
|
||||
],
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: UI Module (Built-in Only)
|
||||
|
||||
For built-in adapters registered in Paperclip's source:
|
||||
|
||||
- `parse-stdout.ts` — converts stdout lines to `TranscriptEntry[]` for the run viewer
|
||||
- `build-config.ts` — converts form values to `adapterConfig` JSON
|
||||
- Config fields React component in `ui/src/adapters/<name>/config-fields.tsx`
|
||||
|
||||
For external adapters, use a self-contained `ui-parser.ts` instead. See the [UI Parser Contract](/adapters/adapter-ui-parser).
|
||||
|
||||
## Step 5: CLI Module
|
||||
|
||||
`format-event.ts` — pretty-prints stdout for `paperclipai run --watch` using `picocolors`.
|
||||
|
||||
## Step 6: Register
|
||||
```ts
|
||||
export function formatStdoutEvent(line: string, debug: boolean): void {
|
||||
if (line.startsWith("[tool-done]")) {
|
||||
console.log(chalk.green(` ✓ ${line}`));
|
||||
} else {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Register (Built-in Only)
|
||||
|
||||
Add the adapter to all three registries:
|
||||
|
||||
@@ -90,6 +185,24 @@ Add the adapter to all three registries:
|
||||
2. `ui/src/adapters/registry.ts`
|
||||
3. `cli/src/adapters/registry.ts`
|
||||
|
||||
For external adapters, registration is automatic — the plugin loader handles it.
|
||||
|
||||
## Session Persistence
|
||||
|
||||
If your agent runtime supports conversation continuity across heartbeats:
|
||||
|
||||
1. Return `sessionParams` from `execute()` (e.g., `{ sessionId: "abc123" }`)
|
||||
2. Read `runtime.sessionParams` on the next wake to resume
|
||||
3. Optionally implement a `sessionCodec` for validation and display
|
||||
|
||||
```ts
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) { /* validate raw session data */ },
|
||||
serialize(params) { /* serialize for storage */ },
|
||||
getDisplayId(params) { /* human-readable session label */ },
|
||||
};
|
||||
```
|
||||
|
||||
## Skills Injection
|
||||
|
||||
Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory:
|
||||
@@ -105,3 +218,10 @@ Make Paperclip skills discoverable to your agent runtime without writing to the
|
||||
- Inject secrets via environment variables, not prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period
|
||||
- The UI parser module runs in a browser sandbox — zero runtime imports, no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [External Adapters](/adapters/external-adapters) — build a standalone adapter plugin
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — ship a custom run-log parser
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — the heartbeat lifecycle
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: External Adapters
|
||||
summary: Build, package, and distribute adapters as plugins without modifying Paperclip source
|
||||
---
|
||||
|
||||
Paperclip supports external adapter plugins that can be installed from npm packages or local directories. External adapters work exactly like built-in adapters — they execute agents, parse output, and render transcripts — but they live in their own package and don't require changes to Paperclip's source code.
|
||||
|
||||
## Built-in vs External
|
||||
|
||||
| | Built-in | External |
|
||||
|---|---|---|
|
||||
| Source location | Inside `paperclip-fork/packages/adapters/` | Separate npm package or local directory |
|
||||
| Registration | Hardcoded in three registries | Loaded at startup via plugin system |
|
||||
| UI parser | Static import at build time | Dynamically loaded from API (see [UI Parser](/adapters/adapter-ui-parser)) |
|
||||
| Distribution | Ships with Paperclip | Published to npm or linked via `file:` |
|
||||
| Updates | Requires Paperclip release | Independent versioning |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal Package Structure
|
||||
|
||||
```
|
||||
my-adapter/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
index.ts # createServerAdapter() factory
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui-parser.ts # Self-contained UI transcript parser
|
||||
```
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-paperclip-adapter",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"paperclip": {
|
||||
"adapterUiParser": "1.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0",
|
||||
"picocolors": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key fields:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `exports["."]` | Entry point — must export `createServerAdapter` |
|
||||
| `exports["./ui-parser"]` | Self-contained UI parser module (optional but recommended) |
|
||||
| `paperclip.adapterUiParser` | Contract version for the UI parser (`"1.0.0"`) |
|
||||
| `files` | Limits what gets published — only `dist/` |
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
## Server Module
|
||||
|
||||
The plugin loader calls `createServerAdapter()` from your package root. This function must return a `ServerAdapterModule`.
|
||||
|
||||
### src/index.ts
|
||||
|
||||
```ts
|
||||
export const type = "my_adapter"; // snake_case, globally unique
|
||||
export const label = "My Agent (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "model-a", label: "Model A" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# my_adapter configuration
|
||||
Use when: ...
|
||||
Don't use when: ...
|
||||
`;
|
||||
|
||||
// Required by plugin-loader convention
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
```
|
||||
|
||||
### src/server/index.ts
|
||||
|
||||
```ts
|
||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { type, models, agentConfigurationDoc } from "../index.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
models,
|
||||
agentConfigurationDoc,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### src/server/execute.ts
|
||||
|
||||
The core execution function. Receives an `AdapterExecutionContext` and returns an `AdapterExecutionResult`.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export async function execute(
|
||||
ctx: AdapterExecutionContext,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const { config, agent, runtime, context, onLog, onMeta } = ctx;
|
||||
|
||||
// 1. Read config with safe helpers
|
||||
const cwd = String(config.cwd ?? "/tmp");
|
||||
const command = String(config.command ?? "my-agent");
|
||||
const timeoutSec = Number(config.timeoutSec ?? 300);
|
||||
|
||||
// 2. Build environment with Paperclip vars injected
|
||||
const env = buildPaperclipEnv(agent);
|
||||
|
||||
// 3. Render prompt template
|
||||
const prompt = config.promptTemplate
|
||||
? renderTemplate(String(config.promptTemplate), {
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
companyId: agent.companyId,
|
||||
runId: ctx.runId,
|
||||
taskId: context.taskId ?? "",
|
||||
taskTitle: context.taskTitle ?? "",
|
||||
})
|
||||
: "Continue your work.";
|
||||
|
||||
// 4. Spawn process
|
||||
const result = await runChildProcess(command, {
|
||||
args: [prompt],
|
||||
cwd,
|
||||
env,
|
||||
timeout: timeoutSec * 1000,
|
||||
graceMs: 10_000,
|
||||
onStdout: (chunk) => onLog("stdout", chunk),
|
||||
onStderr: (chunk) => onLog("stderr", chunk),
|
||||
});
|
||||
|
||||
// 5. Return structured result
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
// Include session state for persistence
|
||||
sessionParams: { /* ... */ },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Available Helpers from `@paperclipai/adapter-utils`
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `runChildProcess(command, opts)` | Spawn a child process with timeout, grace period, and streaming callbacks |
|
||||
| `buildPaperclipEnv(agent)` | Inject `PAPERCLIP_*` environment variables |
|
||||
| `renderTemplate(template, data)` | `{{variable}}` substitution in prompt templates |
|
||||
| `asString(v)`, `asNumber(v)`, `asBoolean(v)` | Safe config value extraction |
|
||||
|
||||
### src/server/test.ts
|
||||
|
||||
Validates the adapter configuration before running. Returns structured diagnostics.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks = [];
|
||||
|
||||
// Example: check CLI is installed
|
||||
checks.push({
|
||||
level: "info",
|
||||
message: "My Agent CLI v1.2.0 detected",
|
||||
code: "cli_detected",
|
||||
});
|
||||
|
||||
// Example: check working directory
|
||||
const cwd = String(ctx.config.cwd ?? "");
|
||||
if (!cwd.startsWith("/")) {
|
||||
checks.push({
|
||||
level: "error",
|
||||
message: `Working directory must be absolute: "${cwd}"`,
|
||||
hint: "Use /home/user/project or /workspace",
|
||||
code: "invalid_cwd",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: checks.some(c => c.level === "error") ? "fail" : "pass",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Check levels:
|
||||
|
||||
| Level | Meaning | Effect |
|
||||
|-------|---------|--------|
|
||||
| `info` | Informational | Shown in test results |
|
||||
| `warn` | Non-blocking issue | Shown with yellow indicator |
|
||||
| `error` | Blocks execution | Prevents agent from running |
|
||||
|
||||
## Installation
|
||||
|
||||
### From npm
|
||||
|
||||
```sh
|
||||
# Via the Paperclip UI
|
||||
# Settings → Adapters → Install from npm → "my-paperclip-adapter"
|
||||
|
||||
# Or via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
```
|
||||
|
||||
### From local directory
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
Local adapters are symlinked into Paperclip's adapter directory. Changes to the source are picked up on server restart.
|
||||
|
||||
### Via adapter-plugins.json
|
||||
|
||||
For development, you can also edit `~/.paperclip/adapter-plugins.json` directly:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"packageName": "my-paperclip-adapter",
|
||||
"localPath": "/home/user/my-adapter",
|
||||
"type": "my_adapter",
|
||||
"installedAt": "2026-03-30T12:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Optional: Session Persistence
|
||||
|
||||
If your agent runtime supports sessions (conversation continuity across heartbeats), implement a session codec:
|
||||
|
||||
```ts
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw) {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
return r.sessionId ? { sessionId: String(r.sessionId) } : null;
|
||||
},
|
||||
serialize(params) {
|
||||
return params?.sessionId ? { sessionId: String(params.sessionId) } : null;
|
||||
},
|
||||
getDisplayId(params) {
|
||||
return params?.sessionId ? String(params.sessionId) : null;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Include it in `createServerAdapter()`:
|
||||
|
||||
```ts
|
||||
return { type, execute, testEnvironment, sessionCodec, /* ... */ };
|
||||
```
|
||||
|
||||
## Optional: Skills Sync
|
||||
|
||||
If your agent runtime supports skills/plugins, implement `listSkills` and `syncSkills`:
|
||||
|
||||
```ts
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
testEnvironment,
|
||||
async listSkills(ctx) {
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
entries: [],
|
||||
warnings: [],
|
||||
};
|
||||
},
|
||||
async syncSkills(ctx, desiredSkills) {
|
||||
// Install desired skills into the runtime
|
||||
return { /* same shape as listSkills */ };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Optional: Model Detection
|
||||
|
||||
If your runtime has a local config file that specifies the default model:
|
||||
|
||||
```ts
|
||||
async function detectModel() {
|
||||
// Read ~/.my-agent/config.yaml or similar
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
source: "~/.my-agent/config.yaml",
|
||||
candidates: ["anthropic/claude-sonnet-4", "openai/gpt-4o"],
|
||||
};
|
||||
}
|
||||
|
||||
return { type, execute, testEnvironment, detectModel: () => detectModel() };
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
Other Paperclip users can then install your adapter by package name from the UI or API.
|
||||
|
||||
## Security
|
||||
|
||||
- Treat agent output as untrusted — parse defensively, never `eval()` agent output
|
||||
- Inject secrets via environment variables, not in prompts
|
||||
- Configure network access controls if the runtime supports them
|
||||
- Always enforce timeout and grace period — don't let agents run forever
|
||||
- The UI parser module runs in a browser sandbox — it must have zero runtime imports and no side effects
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [UI Parser Contract](/adapters/adapter-ui-parser) — add a custom run-log parser so the UI renders your adapter's output correctly
|
||||
- [Creating an Adapter](/adapters/creating-an-adapter) — full walkthrough of adapter internals
|
||||
- [How Agents Work](/guides/agent-developer/how-agents-work) — understand the heartbeat lifecycle your adapter serves
|
||||
+39
-15
@@ -22,43 +22,67 @@ When a heartbeat fires, Paperclip:
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| Hermes Local | `hermes_local` | Runs Hermes CLI locally |
|
||||
| Cursor | `cursor` | Runs Cursor in background mode |
|
||||
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
|
||||
| Hermes Local | `hermes_local` | Runs Hermes CLI locally (`hermes-paperclip-adapter`) |
|
||||
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
### External (plugin) adapters
|
||||
|
||||
These adapters ship as standalone npm packages and are installed via the plugin system:
|
||||
|
||||
| Adapter | Package | Type Key | Description |
|
||||
|---------|---------|----------|-------------|
|
||||
| Droid Local | `@henkey/droid-paperclip-adapter` | `droid_local` | Runs Factory Droid locally |
|
||||
|
||||
## External Adapters
|
||||
|
||||
You can build and distribute adapters as standalone packages — no changes to Paperclip's source code required. External adapters are loaded at startup via the plugin system.
|
||||
|
||||
```sh
|
||||
# Install from npm via API
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"packageName": "my-paperclip-adapter"}'
|
||||
|
||||
# Or link from a local directory
|
||||
curl -X POST http://localhost:3102/api/adapters \
|
||||
-d '{"localPath": "/home/user/my-adapter"}'
|
||||
```
|
||||
|
||||
See [External Adapters](/adapters/external-adapters) for the full guide.
|
||||
|
||||
## Adapter Architecture
|
||||
|
||||
Each adapter is a package with three modules:
|
||||
Each adapter is a package with modules consumed by three registries:
|
||||
|
||||
```
|
||||
packages/adapters/<name>/
|
||||
my-adapter/
|
||||
src/
|
||||
index.ts # Shared metadata (type, label, models)
|
||||
server/
|
||||
execute.ts # Core execution logic
|
||||
parse.ts # Output parsing
|
||||
test.ts # Environment diagnostics
|
||||
ui/
|
||||
parse-stdout.ts # Stdout -> transcript entries for run viewer
|
||||
build-config.ts # Form values -> adapterConfig JSON
|
||||
ui-parser.ts # Self-contained UI transcript parser (for external adapters)
|
||||
cli/
|
||||
format-event.ts # Terminal output for `paperclipai run --watch`
|
||||
```
|
||||
|
||||
Three registries consume these modules:
|
||||
|
||||
| Registry | What it does |
|
||||
|----------|-------------|
|
||||
| **Server** | Executes agents, captures results |
|
||||
| **UI** | Renders run transcripts, provides config forms |
|
||||
| **CLI** | Formats terminal output for live watching |
|
||||
| Registry | What it does | Source |
|
||||
|----------|-------------|--------|
|
||||
| **Server** | Executes agents, captures results | `createServerAdapter()` from package root |
|
||||
| **UI** | Renders run transcripts, provides config forms | `ui-parser.js` (dynamic) or static import (built-in) |
|
||||
| **CLI** | Formats terminal output for live watching | Static import |
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin
|
||||
- **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)
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) or [build an external adapter plugin](/adapters/external-adapters)
|
||||
|
||||
## UI Parser Contract
|
||||
|
||||
External adapters can ship a self-contained UI parser that tells the Paperclip web UI how to render their stdout. Without it, the UI uses a generic shell parser. See the [UI Parser Contract](/adapters/adapter-ui-parser) for details.
|
||||
|
||||
@@ -37,14 +37,18 @@ Built-in adapters:
|
||||
- `claude_local`: runs your local `claude` CLI
|
||||
- `codex_local`: runs your local `codex` CLI
|
||||
- `opencode_local`: runs your local `opencode` CLI
|
||||
- `hermes_local`: runs your local `hermes` CLI
|
||||
- `cursor`: runs Cursor in background mode
|
||||
- `pi_local`: runs an embedded Pi agent locally
|
||||
- `hermes_local`: runs your local `hermes` CLI (`hermes-paperclip-adapter`)
|
||||
- `openclaw_gateway`: connects to an OpenClaw gateway endpoint
|
||||
- `process`: generic shell command adapter
|
||||
- `http`: calls an external HTTP endpoint
|
||||
|
||||
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
||||
External plugin adapters (install via the adapter manager or API):
|
||||
|
||||
- `droid_local`: runs your local Factory Droid CLI (`@henkey/droid-paperclip-adapter`)
|
||||
|
||||
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `droid_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
||||
|
||||
## 3.2 Runtime behavior
|
||||
|
||||
@@ -173,7 +177,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r
|
||||
|
||||
## 10. Minimal setup checklist
|
||||
|
||||
1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`).
|
||||
1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). External plugins like `droid_local` are also available via the adapter manager.
|
||||
2. Set `cwd` to the target workspace (for local adapters).
|
||||
3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle.
|
||||
4. Configure heartbeat policy (timer and/or assignment wakeups).
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"adapters/codex-local",
|
||||
"adapters/process",
|
||||
"adapters/http",
|
||||
"adapters/external-adapters",
|
||||
"adapters/adapter-ui-parser",
|
||||
"adapters/creating-an-adapter"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ Each vote creates two local records:
|
||||
|
||||
All data lives in your local Paperclip database. Nothing leaves your machine unless you explicitly choose to share.
|
||||
|
||||
When a vote is marked for sharing, Paperclip also queues the trace bundle for background export through the Telemetry Backend. The app server never uploads raw feedback trace bundles directly to object storage.
|
||||
When a vote is marked for sharing, Paperclip immediately tries to upload the trace bundle through the Telemetry Backend. The upload is compressed in transit so full trace bundles stay under gateway size limits. If that immediate push fails, the trace is left in a retriable failed state for later flush attempts. The app server never uploads raw feedback trace bundles directly to object storage.
|
||||
|
||||
## Viewing your votes
|
||||
|
||||
@@ -148,6 +148,8 @@ Open any file in `traces/` to see:
|
||||
|
||||
Open `full-traces/<issue>-<trace>/bundle.json` to see the expanded export metadata, including capture notes, adapter type, integrity metadata, and the inventory of raw files written alongside it.
|
||||
|
||||
Each entry in `bundle.json.files[]` includes the actual captured file payload under `contents`, not just a pathname. For text artifacts this is stored as UTF-8 text; binary artifacts use base64 plus an `encoding` marker.
|
||||
|
||||
Built-in local adapters now export their native session artifacts more directly:
|
||||
|
||||
- `codex_local`: `adapter/codex/session.jsonl`
|
||||
@@ -168,19 +170,21 @@ Your preference is saved per-company. You can change it any time via the feedbac
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `local_only` | Vote stored locally, not marked for sharing |
|
||||
| `pending` | Marked for sharing, waiting to be sent |
|
||||
| `pending` | Marked for sharing, saved locally, and waiting for the immediate upload attempt |
|
||||
| `sent` | Successfully transmitted |
|
||||
| `failed` | Transmission attempted but failed (will retry) |
|
||||
| `failed` | Transmission attempted but failed (for example the backend is unreachable or not configured); later flushes retry once a backend is available |
|
||||
|
||||
Your local database always retains the full vote and trace data regardless of sharing status.
|
||||
|
||||
## Remote sync
|
||||
|
||||
Votes you choose to share are queued as `pending` traces and flushed by the server's background worker to the Telemetry Backend. The Telemetry Backend validates the request, then persists the bundle into its configured object storage.
|
||||
Votes you choose to share are sent to the Telemetry Backend immediately from the vote request. The server also keeps a background flush worker so failed traces can retry later. The Telemetry Backend validates the request, then persists the bundle into its configured object storage.
|
||||
|
||||
- App server responsibility: build the bundle, POST it to Telemetry Backend, update trace status
|
||||
- Telemetry Backend responsibility: authenticate the request, validate payload shape, compress/store the bundle, return the final object key
|
||||
- Retry behavior: failed uploads move to `failed` with an error message in `failureReason`, and the worker retries them on later ticks
|
||||
- Default endpoint: when no feedback export backend URL is configured, Paperclip falls back to `https://telemetry.paperclip.ing`
|
||||
- Important nuance: the uploaded object is a snapshot of the full bundle at vote time. If you fetch a local bundle later and the underlying adapter session file has continued to grow, the local regenerated bundle may be larger than the already-uploaded snapshot for that same trace.
|
||||
|
||||
Exported objects use a deterministic key pattern so they are easy to inspect:
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export type {
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
|
||||
@@ -68,6 +68,7 @@ export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePa
|
||||
case "stderr":
|
||||
case "system":
|
||||
case "stdout":
|
||||
case "diff":
|
||||
return { ...entry, text: redactHomePathUserSegments(entry.text, opts) };
|
||||
case "tool_call":
|
||||
return {
|
||||
|
||||
@@ -193,6 +193,174 @@ export function joinPromptSections(
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
type PaperclipWakeIssue = {
|
||||
id: string | null;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeComment = {
|
||||
id: string | null;
|
||||
issueId: string | null;
|
||||
body: string;
|
||||
bodyTruncated: boolean;
|
||||
createdAt: string | null;
|
||||
authorType: string | null;
|
||||
authorId: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
comments: PaperclipWakeComment[];
|
||||
requestedCount: number;
|
||||
includedCount: number;
|
||||
missingCount: number;
|
||||
truncated: boolean;
|
||||
fallbackFetchNeeded: boolean;
|
||||
};
|
||||
|
||||
function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null {
|
||||
const issue = parseObject(value);
|
||||
const id = asString(issue.id, "").trim() || null;
|
||||
const identifier = asString(issue.identifier, "").trim() || null;
|
||||
const title = asString(issue.title, "").trim() || null;
|
||||
const status = asString(issue.status, "").trim() || null;
|
||||
const priority = asString(issue.priority, "").trim() || null;
|
||||
if (!id && !identifier && !title) return null;
|
||||
return {
|
||||
id,
|
||||
identifier,
|
||||
title,
|
||||
status,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | null {
|
||||
const comment = parseObject(value);
|
||||
const author = parseObject(comment.author);
|
||||
const body = asString(comment.body, "");
|
||||
if (!body.trim()) return null;
|
||||
return {
|
||||
id: asString(comment.id, "").trim() || null,
|
||||
issueId: asString(comment.issueId, "").trim() || null,
|
||||
body,
|
||||
bodyTruncated: asBoolean(comment.bodyTruncated, false),
|
||||
createdAt: asString(comment.createdAt, "").trim() || null,
|
||||
authorType: asString(author.type, "").trim() || null,
|
||||
authorId: asString(author.id, "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
|
||||
const payload = parseObject(value);
|
||||
const comments = Array.isArray(payload.comments)
|
||||
? payload.comments
|
||||
.map((entry) => normalizePaperclipWakeComment(entry))
|
||||
.filter((entry): entry is PaperclipWakeComment => Boolean(entry))
|
||||
: [];
|
||||
const commentWindow = parseObject(payload.commentWindow);
|
||||
const commentIds = Array.isArray(payload.commentIds)
|
||||
? payload.commentIds
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
|
||||
if (comments.length === 0 && commentIds.length === 0) return null;
|
||||
|
||||
return {
|
||||
reason: asString(payload.reason, "").trim() || null,
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
comments,
|
||||
requestedCount: asNumber(commentWindow.requestedCount, comments.length || commentIds.length),
|
||||
includedCount: asNumber(commentWindow.includedCount, comments.length),
|
||||
missingCount: asNumber(commentWindow.missingCount, 0),
|
||||
truncated: asBoolean(payload.truncated, false),
|
||||
fallbackFetchNeeded: asBoolean(payload.fallbackFetchNeeded, false),
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyPaperclipWakePayload(value: unknown): string | null {
|
||||
const normalized = normalizePaperclipWakePayload(value);
|
||||
if (!normalized) return null;
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
export function renderPaperclipWakePrompt(
|
||||
value: unknown,
|
||||
options: { resumedSession?: boolean } = {},
|
||||
): string {
|
||||
const normalized = normalizePaperclipWakePayload(value);
|
||||
if (!normalized) return "";
|
||||
const resumedSession = options.resumedSession === true;
|
||||
|
||||
const lines = resumedSession
|
||||
? [
|
||||
"## Paperclip Resume Delta",
|
||||
"",
|
||||
"You are resuming an existing Paperclip session.",
|
||||
"This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.",
|
||||
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
|
||||
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
`- latest comment id: ${normalized.latestCommentId ?? "unknown"}`,
|
||||
`- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`,
|
||||
]
|
||||
: [
|
||||
"## Paperclip Wake Payload",
|
||||
"",
|
||||
"Treat this wake payload as the highest-priority change for the current heartbeat.",
|
||||
"This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.",
|
||||
"Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.",
|
||||
"Use this inline wake data first before refetching the issue thread.",
|
||||
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
`- latest comment id: ${normalized.latestCommentId ?? "unknown"}`,
|
||||
`- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`,
|
||||
];
|
||||
|
||||
if (normalized.issue?.status) {
|
||||
lines.push(`- issue status: ${normalized.issue.status}`);
|
||||
}
|
||||
if (normalized.issue?.priority) {
|
||||
lines.push(`- issue priority: ${normalized.issue.priority}`);
|
||||
}
|
||||
if (normalized.missingCount > 0) {
|
||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||
}
|
||||
|
||||
lines.push("", "New comments in order:");
|
||||
|
||||
for (const [index, comment] of normalized.comments.entries()) {
|
||||
const authorLabel = comment.authorId
|
||||
? `${comment.authorType ?? "unknown"} ${comment.authorId}`
|
||||
: comment.authorType ?? "unknown";
|
||||
lines.push(
|
||||
`${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`,
|
||||
comment.body,
|
||||
);
|
||||
if (comment.bodyTruncated) {
|
||||
lines.push("[comment body truncated]");
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
|
||||
"codex_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"hermes_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
@@ -76,6 +77,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
hermes_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -261,6 +261,34 @@ export interface ProviderQuotaResult {
|
||||
windows: QuotaWindow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter config schema — declarative UI config for external adapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConfigFieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Optional group key for categorizing options (e.g. provider name) */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface ConfigFieldSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "select" | "toggle" | "number" | "textarea" | "combobox";
|
||||
options?: ConfigFieldOption[];
|
||||
default?: unknown;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
group?: string;
|
||||
/** Optional metadata — not rendered, but available to custom UI logic */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterConfigSchema {
|
||||
fields: ConfigFieldSchema[];
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
@@ -292,7 +320,14 @@ export interface ServerAdapterModule {
|
||||
* Returns the detected model/provider and the config source, or null if
|
||||
* the adapter does not support detection or no config is found.
|
||||
*/
|
||||
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
|
||||
detectModel?: () => Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null>;
|
||||
/**
|
||||
* Optional: return a declarative config schema so the UI can render
|
||||
* adapter-specific form fields without shipping React components.
|
||||
* Dynamic options (e.g. scanning a profiles directory) should be
|
||||
* resolved inside this method — the caller receives a fully hydrated schema.
|
||||
*/
|
||||
getConfigSchema?: () => Promise<AdapterConfigSchema> | AdapterConfigSchema;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -309,7 +344,8 @@ export type TranscriptEntry =
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string };
|
||||
| { kind: "stdout"; ts: string; text: string }
|
||||
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string };
|
||||
|
||||
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
|
||||
|
||||
@@ -353,4 +389,6 @@ export interface CreateConfigValues {
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
/** Arbitrary key-value pairs populated by schema-driven config fields. */
|
||||
adapterSchemaValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ Core fields:
|
||||
- chrome (boolean, optional): pass --chrome when running Claude
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- maxTurnsPerRun (number, optional): max turns for one run
|
||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
||||
- dangerouslySkipPermissions (boolean, optional, default true): pass --dangerously-skip-permissions to claude; defaults to true because Paperclip runs Claude in headless --print mode where interactive permission prompts cannot be answered
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
@@ -170,6 +172,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
@@ -189,6 +192,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
@@ -317,7 +323,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const commandNotes = instructionsFilePath
|
||||
@@ -398,20 +404,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function testEnvironment(
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
resolveCommandForLogs,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -313,6 +315,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
@@ -331,6 +334,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
@@ -434,11 +440,36 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
const repoAgentsNote =
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
|
||||
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 renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) {
|
||||
return [repoAgentsNote];
|
||||
}
|
||||
if (instructionsPrefix.length > 0) {
|
||||
if (shouldUseResumeDeltaPrompt) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
repoAgentsNote,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
@@ -450,25 +481,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
repoAgentsNote,
|
||||
];
|
||||
})();
|
||||
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 renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
promptInstructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
@@ -476,6 +494,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -219,6 +221,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
@@ -237,6 +240,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
@@ -352,16 +358,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
@@ -370,6 +379,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
@@ -193,12 +195,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
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 (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
@@ -295,17 +299,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
@@ -315,6 +322,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
|
||||
@@ -3,7 +3,14 @@ import type {
|
||||
AdapterExecutionResult,
|
||||
AdapterRuntimeServiceReport,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
buildPaperclipEnv,
|
||||
parseObject,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import crypto, { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
@@ -335,7 +342,11 @@ function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: Wak
|
||||
return paperclipEnv;
|
||||
}
|
||||
|
||||
function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string>): string {
|
||||
function buildWakeText(
|
||||
payload: WakePayload,
|
||||
paperclipEnv: Record<string, string>,
|
||||
structuredWakePrompt: string,
|
||||
): string {
|
||||
const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json";
|
||||
const orderedKeys = [
|
||||
"PAPERCLIP_RUN_ID",
|
||||
@@ -404,6 +415,12 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record<string, string
|
||||
"- POST /api/issues/{issueId}/comments",
|
||||
"- PATCH /api/issues/{issueId}",
|
||||
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
||||
...(structuredWakePrompt
|
||||
? [
|
||||
"",
|
||||
structuredWakePrompt,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
"Complete the workflow in this run.",
|
||||
];
|
||||
@@ -415,6 +432,17 @@ function appendWakeText(baseText: string, wakeText: string): string {
|
||||
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
||||
}
|
||||
|
||||
function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJson: string): string {
|
||||
const sections = [
|
||||
structuredWakePrompt.trim(),
|
||||
"Structured wake payload JSON:",
|
||||
"```json",
|
||||
structuredWakeJson,
|
||||
"```",
|
||||
].filter((entry) => entry.trim().length > 0);
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
function buildStandardPaperclipPayload(
|
||||
ctx: AdapterExecutionContext,
|
||||
wakePayload: WakePayload,
|
||||
@@ -447,6 +475,10 @@ function buildStandardPaperclipPayload(
|
||||
approvalStatus: wakePayload.approvalStatus,
|
||||
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
||||
};
|
||||
const structuredWake = parseObject(ctx.context.paperclipWake);
|
||||
if (Object.keys(structuredWake).length > 0) {
|
||||
standardPaperclip.wake = structuredWake;
|
||||
}
|
||||
|
||||
if (workspace) {
|
||||
standardPaperclip.workspace = workspace;
|
||||
@@ -1053,7 +1085,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const wakePayload = buildWakePayload(ctx);
|
||||
const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload);
|
||||
const wakeText = buildWakeText(wakePayload, paperclipEnv);
|
||||
const structuredWakePrompt = renderPaperclipWakePrompt(ctx.context.paperclipWake);
|
||||
const structuredWakeJson = stringifyPaperclipWakePayload(ctx.context.paperclipWake);
|
||||
const wakeText = buildWakeText(
|
||||
wakePayload,
|
||||
paperclipEnv,
|
||||
structuredWakeJson
|
||||
? joinWakePayloadSections(structuredWakePrompt, structuredWakeJson)
|
||||
: structuredWakePrompt,
|
||||
);
|
||||
|
||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
||||
const configuredSessionKey = nonEmpty(ctx.config.sessionKey);
|
||||
@@ -1075,6 +1115,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
idempotencyKey: ctx.runId,
|
||||
};
|
||||
delete agentParams.text;
|
||||
agentParams.paperclip = paperclipPayload;
|
||||
|
||||
const configuredAgentId = nonEmpty(ctx.config.agentId);
|
||||
if (configuredAgentId && !nonEmpty(agentParams.agentId)) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
ensurePathInEnv,
|
||||
resolveCommandForLogs,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
runChildProcess,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
@@ -154,12 +156,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
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 (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
@@ -222,7 +226,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const resolvedInstructionsFilePath = instructionsFilePath
|
||||
? path.resolve(cwd, instructionsFilePath)
|
||||
@@ -271,15 +274,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
@@ -287,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
||||
@@ -177,6 +179,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
@@ -184,6 +187,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
@@ -298,14 +302,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
context,
|
||||
};
|
||||
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
|
||||
const renderedHeartbeatPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession });
|
||||
const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0;
|
||||
const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const userPrompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
renderedHeartbeatPrompt,
|
||||
]);
|
||||
@@ -313,6 +320,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
systemPromptChars: renderedSystemPromptExtension.length,
|
||||
promptChars: userPrompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
|
||||
import { ensurePostgresDatabase } from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function createTempDatabase(): Promise<string> {
|
||||
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-backup-");
|
||||
cleanups.push(db.cleanup);
|
||||
return db.connectionString;
|
||||
}
|
||||
|
||||
async function createSiblingDatabase(connectionString: string, databaseName: string): Promise<string> {
|
||||
const adminUrl = new URL(connectionString);
|
||||
adminUrl.pathname = "/postgres";
|
||||
await ensurePostgresDatabase(adminUrl.toString(), databaseName);
|
||||
const targetUrl = new URL(connectionString);
|
||||
targetUrl.pathname = `/${databaseName}`;
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
const cleanup = cleanups.pop();
|
||||
await cleanup?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres backup tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createBufferedTextFileWriter", () => {
|
||||
it("preserves line boundaries across buffered flushes", async () => {
|
||||
const tempDir = createTempDir("paperclip-buffered-writer-");
|
||||
const outputPath = path.join(tempDir, "backup.sql");
|
||||
const writer = createBufferedTextFileWriter(outputPath, 16);
|
||||
const lines = [
|
||||
"-- header",
|
||||
"BEGIN;",
|
||||
"",
|
||||
"INSERT INTO test VALUES (1);",
|
||||
"-- footer",
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
writer.emit(line);
|
||||
}
|
||||
|
||||
await writer.close();
|
||||
|
||||
expect(fs.readFileSync(outputPath, "utf8")).toBe(lines.join("\n"));
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("runDatabaseBackup", () => {
|
||||
it(
|
||||
"backs up and restores large table payloads without materializing one giant string",
|
||||
async () => {
|
||||
const sourceConnectionString = await createTempDatabase();
|
||||
const restoreConnectionString = await createSiblingDatabase(
|
||||
sourceConnectionString,
|
||||
"paperclip_restore_target",
|
||||
);
|
||||
const backupDir = createTempDir("paperclip-db-backup-output-");
|
||||
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
|
||||
try {
|
||||
await sourceSql.unsafe(`
|
||||
CREATE TYPE "public"."backup_test_state" AS ENUM ('pending', 'done');
|
||||
`);
|
||||
await sourceSql.unsafe(`
|
||||
CREATE TABLE "public"."backup_test_records" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"payload" text NOT NULL,
|
||||
"state" "public"."backup_test_state" NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
const payload = "x".repeat(8192);
|
||||
for (let index = 0; index < 160; index += 1) {
|
||||
const createdAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index));
|
||||
await sourceSql`
|
||||
INSERT INTO "public"."backup_test_records" (
|
||||
"title",
|
||||
"payload",
|
||||
"state",
|
||||
"metadata",
|
||||
"created_at"
|
||||
)
|
||||
VALUES (
|
||||
${`row-${index}`},
|
||||
${payload},
|
||||
${index % 2 === 0 ? "pending" : "done"}::"public"."backup_test_state",
|
||||
${JSON.stringify({ index, even: index % 2 === 0 })}::jsonb,
|
||||
${createdAt}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir,
|
||||
retentionDays: 7,
|
||||
filenamePrefix: "paperclip-test",
|
||||
});
|
||||
|
||||
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/);
|
||||
expect(result.sizeBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(fs.existsSync(result.backupFile)).toBe(true);
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile: result.backupFile,
|
||||
});
|
||||
|
||||
const counts = await restoreSql.unsafe<{ count: number }[]>(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM "public"."backup_test_records"
|
||||
`);
|
||||
expect(counts[0]?.count).toBe(160);
|
||||
|
||||
const sampleRows = await restoreSql.unsafe<{
|
||||
title: string;
|
||||
payload: string;
|
||||
state: string;
|
||||
metadata: { index: number; even: boolean };
|
||||
}[]>(`
|
||||
SELECT "title", "payload", "state"::text AS "state", "metadata"
|
||||
FROM "public"."backup_test_records"
|
||||
WHERE "title" IN ('row-0', 'row-159')
|
||||
ORDER BY "title"
|
||||
`);
|
||||
expect(sampleRows).toEqual([
|
||||
{
|
||||
title: "row-0",
|
||||
payload,
|
||||
state: "pending",
|
||||
metadata: { index: 0, even: true },
|
||||
},
|
||||
{
|
||||
title: "row-159",
|
||||
payload,
|
||||
state: "done",
|
||||
metadata: { index: 159, even: false },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
@@ -47,6 +47,7 @@ type TableDefinition = {
|
||||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||
|
||||
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
|
||||
|
||||
@@ -141,6 +142,102 @@ function tableKey(schemaName: string, tableName: string): string {
|
||||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
|
||||
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
||||
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
||||
let bufferedLines: string[] = [];
|
||||
let bufferedBytes = 0;
|
||||
let firstChunk = true;
|
||||
let closed = false;
|
||||
let streamError: Error | null = null;
|
||||
let pendingWrite = Promise.resolve();
|
||||
|
||||
stream.on("error", (error) => {
|
||||
streamError = error;
|
||||
});
|
||||
|
||||
const writeChunk = async (chunk: string): Promise<void> => {
|
||||
if (streamError) throw streamError;
|
||||
const canContinue = stream.write(chunk);
|
||||
if (!canContinue) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handleDrain = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const cleanup = () => {
|
||||
stream.off("drain", handleDrain);
|
||||
stream.off("error", handleError);
|
||||
};
|
||||
stream.once("drain", handleDrain);
|
||||
stream.once("error", handleError);
|
||||
});
|
||||
}
|
||||
if (streamError) throw streamError;
|
||||
};
|
||||
|
||||
const flushBufferedLines = () => {
|
||||
if (bufferedLines.length === 0) return;
|
||||
const linesToWrite = bufferedLines;
|
||||
bufferedLines = [];
|
||||
bufferedBytes = 0;
|
||||
const chunkBody = linesToWrite.join("\n");
|
||||
const chunk = firstChunk ? chunkBody : `\n${chunkBody}`;
|
||||
firstChunk = false;
|
||||
pendingWrite = pendingWrite.then(() => writeChunk(chunk));
|
||||
};
|
||||
|
||||
return {
|
||||
emit(line: string) {
|
||||
if (closed) {
|
||||
throw new Error(`Cannot write to closed backup file: ${filePath}`);
|
||||
}
|
||||
if (streamError) throw streamError;
|
||||
bufferedLines.push(line);
|
||||
bufferedBytes += Buffer.byteLength(line, "utf8") + 1;
|
||||
if (bufferedBytes >= flushThreshold) {
|
||||
flushBufferedLines();
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
flushBufferedLines();
|
||||
await pendingWrite;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (streamError) {
|
||||
reject(streamError);
|
||||
return;
|
||||
}
|
||||
stream.end((error?: Error | null) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
if (streamError) throw streamError;
|
||||
},
|
||||
async abort() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
bufferedLines = [];
|
||||
bufferedBytes = 0;
|
||||
stream.destroy();
|
||||
await pendingWrite.catch(() => {});
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch {
|
||||
// Preserve the original backup failure if temporary file cleanup also fails.
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
|
||||
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
|
||||
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
|
||||
@@ -149,12 +246,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const writer = createBufferedTextFileWriter(backupFile);
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
|
||||
const lines: string[] = [];
|
||||
const emit = (line: string) => lines.push(line);
|
||||
const emit = (line: string) => writer.emit(line);
|
||||
const emitStatement = (statement: string) => {
|
||||
emit(statement);
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
@@ -503,10 +602,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
emitStatement("COMMIT;");
|
||||
emit("");
|
||||
|
||||
// Write the backup file
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
await writeFile(backupFile, lines.join("\n"), "utf8");
|
||||
await writer.close();
|
||||
|
||||
const sizeBytes = statSync(backupFile).size;
|
||||
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
||||
@@ -516,6 +612,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
sizeBytes,
|
||||
prunedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
await writer.abort();
|
||||
throw error;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ export interface WorkerToHostMethods {
|
||||
result: IssueComment[],
|
||||
];
|
||||
"issues.createComment": [
|
||||
params: { issueId: string; body: string; companyId: string },
|
||||
params: { issueId: string; body: string; companyId: string; authorAgentId?: string },
|
||||
result: IssueComment,
|
||||
];
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return issueComments.get(issueId) ?? [];
|
||||
},
|
||||
async createComment(issueId, body, companyId) {
|
||||
async createComment(issueId, body, companyId, options) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
@@ -416,7 +416,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
id: randomUUID(),
|
||||
companyId: parentIssue.companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorAgentId: options?.authorAgentId ?? null,
|
||||
authorUserId: null,
|
||||
body,
|
||||
createdAt: now,
|
||||
|
||||
@@ -909,7 +909,12 @@ export interface PluginIssuesClient {
|
||||
companyId: string,
|
||||
): Promise<Issue>;
|
||||
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
|
||||
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
|
||||
createComment(
|
||||
issueId: string,
|
||||
body: string,
|
||||
companyId: string,
|
||||
options?: { authorAgentId?: string },
|
||||
): Promise<IssueComment>;
|
||||
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||
documents: PluginIssueDocumentsClient;
|
||||
}
|
||||
|
||||
@@ -610,8 +610,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
return callHost("issues.listComments", { issueId, companyId });
|
||||
},
|
||||
|
||||
async createComment(issueId: string, body: string, companyId: string) {
|
||||
return callHost("issues.createComment", { issueId, body, companyId });
|
||||
async createComment(issueId: string, body: string, companyId: string, options?: { authorAgentId?: string }) {
|
||||
return callHost("issues.createComment", { issueId, body, companyId, authorAgentId: options?.authorAgentId });
|
||||
},
|
||||
|
||||
documents: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { AGENT_ADAPTER_TYPES } from "./constants.js";
|
||||
|
||||
export const agentAdapterTypeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.default("process")
|
||||
.describe(`Known built-in adapters: ${AGENT_ADAPTER_TYPES.join(", ")}. External adapters may register additional non-empty string types at runtime.`);
|
||||
|
||||
export const optionalAgentAdapterTypeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acceptInviteSchema, createAgentSchema, updateAgentSchema } from "./index.js";
|
||||
|
||||
describe("dynamic adapter type validation schemas", () => {
|
||||
it("accepts external adapter types in create/update agent schemas", () => {
|
||||
expect(
|
||||
createAgentSchema.parse({
|
||||
name: "External Agent",
|
||||
adapterType: "external_adapter",
|
||||
}).adapterType,
|
||||
).toBe("external_adapter");
|
||||
|
||||
expect(
|
||||
updateAgentSchema.parse({
|
||||
adapterType: "external_adapter",
|
||||
}).adapterType,
|
||||
).toBe("external_adapter");
|
||||
});
|
||||
|
||||
it("still rejects blank adapter types", () => {
|
||||
expect(() =>
|
||||
createAgentSchema.parse({
|
||||
name: "Blank Adapter",
|
||||
adapterType: " ",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts external adapter types in invite acceptance schema", () => {
|
||||
expect(
|
||||
acceptInviteSchema.parse({
|
||||
requestType: "agent",
|
||||
agentName: "External Joiner",
|
||||
adapterType: "external_adapter",
|
||||
}).adapterType,
|
||||
).toBe("external_adapter");
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,8 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw_gateway",
|
||||
"hermes_local",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {});
|
||||
|
||||
export const AGENT_ROLES = [
|
||||
"ceo",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ExecutionWorkspace } from "./types/workspace-runtime.js";
|
||||
|
||||
type ExecutionWorkspaceGuardTarget = Pick<ExecutionWorkspace, "closedAt" | "mode" | "name" | "status">;
|
||||
|
||||
const CLOSED_EXECUTION_WORKSPACE_STATUSES = new Set<ExecutionWorkspace["status"]>(["archived", "cleanup_failed"]);
|
||||
|
||||
export function isClosedIsolatedExecutionWorkspace(
|
||||
workspace: Pick<ExecutionWorkspaceGuardTarget, "closedAt" | "mode" | "status"> | null | undefined,
|
||||
): boolean {
|
||||
if (!workspace) return false;
|
||||
if (workspace.mode !== "isolated_workspace") return false;
|
||||
return workspace.closedAt != null || CLOSED_EXECUTION_WORKSPACE_STATUSES.has(workspace.status);
|
||||
}
|
||||
|
||||
export function getClosedIsolatedExecutionWorkspaceMessage(
|
||||
workspace: Pick<ExecutionWorkspaceGuardTarget, "name">,
|
||||
): string {
|
||||
return `This issue is linked to the closed workspace "${workspace.name}". Move it to an open workspace before adding comments or resuming work.`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEPLOYMENT_MODES,
|
||||
@@ -350,6 +351,11 @@ export {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
} from "./types/feedback.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
} from "./execution-workspace-guards.js";
|
||||
|
||||
export {
|
||||
instanceGeneralSettingsSchema,
|
||||
patchInstanceGeneralSettingsSchema,
|
||||
@@ -594,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
|
||||
export {
|
||||
AGENT_MENTION_SCHEME,
|
||||
PROJECT_MENTION_SCHEME,
|
||||
SKILL_MENTION_SCHEME,
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractSkillMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
extractProjectMentionIds,
|
||||
type ParsedAgentMention,
|
||||
type ParsedProjectMention,
|
||||
type ParsedSkillMention,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractSkillMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
describe("project-mentions", () => {
|
||||
@@ -26,4 +29,13 @@ describe("project-mentions", () => {
|
||||
});
|
||||
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
|
||||
});
|
||||
|
||||
it("round-trips skill mentions with slug metadata", () => {
|
||||
const href = buildSkillMentionHref("skill-123", "release-changelog");
|
||||
expect(parseSkillMentionHref(href)).toEqual({
|
||||
skillId: "skill-123",
|
||||
slug: "release-changelog",
|
||||
});
|
||||
expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const PROJECT_MENTION_SCHEME = "project://";
|
||||
export const AGENT_MENTION_SCHEME = "agent://";
|
||||
export const SKILL_MENTION_SCHEME = "skill://";
|
||||
|
||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
||||
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
|
||||
@@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
|
||||
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
||||
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
|
||||
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
|
||||
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
|
||||
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
|
||||
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
|
||||
export interface ParsedProjectMention {
|
||||
projectId: string;
|
||||
@@ -19,6 +22,11 @@ export interface ParsedAgentMention {
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedSkillMention {
|
||||
skillId: string;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
function normalizeHexColor(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim();
|
||||
@@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
|
||||
const trimmedSkillId = skillId.trim();
|
||||
const normalizedSlug = normalizeSkillSlug(slug ?? null);
|
||||
if (!normalizedSlug) {
|
||||
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`;
|
||||
}
|
||||
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`;
|
||||
}
|
||||
|
||||
export function parseSkillMentionHref(href: string): ParsedSkillMention | null {
|
||||
if (!href.startsWith(SKILL_MENTION_SCHEME)) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "skill:") return null;
|
||||
|
||||
const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
|
||||
if (!skillId) return null;
|
||||
|
||||
return {
|
||||
skillId,
|
||||
slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractProjectMentionIds(markdown: string): string[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
@@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function extractSkillMentionIds(markdown: string): string[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
const re = new RegExp(SKILL_MENTION_LINK_RE);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(markdown)) !== null) {
|
||||
const parsed = parseSkillMentionHref(match[1]);
|
||||
if (parsed) ids.add(parsed.skillId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function normalizeAgentIcon(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeSkillSlug(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export class TelemetryClient {
|
||||
app,
|
||||
schemaVersion,
|
||||
installId: state.installId,
|
||||
version: this.version,
|
||||
events,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -23,6 +23,48 @@ export function trackCompanyImported(
|
||||
});
|
||||
}
|
||||
|
||||
export function trackProjectCreated(client: TelemetryClient): void {
|
||||
client.track("project.created");
|
||||
}
|
||||
|
||||
export function trackRoutineCreated(client: TelemetryClient): void {
|
||||
client.track("routine.created");
|
||||
}
|
||||
|
||||
export function trackRoutineRun(
|
||||
client: TelemetryClient,
|
||||
dims: { source: string; status: string },
|
||||
): void {
|
||||
client.track("routine.run", {
|
||||
source: dims.source,
|
||||
status: dims.status,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackGoalCreated(
|
||||
client: TelemetryClient,
|
||||
dims?: { goalLevel?: string | null },
|
||||
): void {
|
||||
client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined);
|
||||
}
|
||||
|
||||
export function trackAgentCreated(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
): void {
|
||||
client.track("agent.created", { agent_role: dims.agentRole });
|
||||
}
|
||||
|
||||
export function trackSkillImported(
|
||||
client: TelemetryClient,
|
||||
dims: { sourceType: string; skillRef?: string | null },
|
||||
): void {
|
||||
client.track("skill.imported", {
|
||||
source_type: dims.sourceType,
|
||||
...(dims.skillRef ? { skill_ref: dims.skillRef } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function trackAgentFirstHeartbeat(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
|
||||
@@ -5,6 +5,12 @@ export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
trackProjectCreated,
|
||||
trackRoutineCreated,
|
||||
trackRoutineRun,
|
||||
trackGoalCreated,
|
||||
trackAgentCreated,
|
||||
trackSkillImported,
|
||||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TelemetryEventEnvelope {
|
||||
app: string;
|
||||
schemaVersion: string;
|
||||
installId: string;
|
||||
version: string;
|
||||
events: TelemetryEvent[];
|
||||
}
|
||||
|
||||
@@ -31,6 +32,12 @@ export type TelemetryEventName =
|
||||
| "install.started"
|
||||
| "install.completed"
|
||||
| "company.imported"
|
||||
| "project.created"
|
||||
| "routine.created"
|
||||
| "routine.run"
|
||||
| "goal.created"
|
||||
| "agent.created"
|
||||
| "skill.imported"
|
||||
| "agent.first_heartbeat"
|
||||
| "agent.task_completed"
|
||||
| "error.handler_crash"
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface Issue {
|
||||
mentionedProjects?: Project[];
|
||||
myLastTouchAt?: Date | null;
|
||||
lastExternalCommentAt?: Date | null;
|
||||
lastActivityAt?: Date | null;
|
||||
isUnreadForMe?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
INVITE_JOIN_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
JOIN_REQUEST_TYPES,
|
||||
PERMISSION_KEYS,
|
||||
} from "../constants.js";
|
||||
import { optionalAgentAdapterTypeSchema } from "../adapter-type.js";
|
||||
|
||||
export const createCompanyInviteSchema = z.object({
|
||||
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
|
||||
@@ -26,7 +26,7 @@ export type CreateOpenClawInvitePrompt = z.infer<
|
||||
export const acceptInviteSchema = z.object({
|
||||
requestType: z.enum(JOIN_REQUEST_TYPES),
|
||||
agentName: z.string().min(1).max(120).optional(),
|
||||
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(),
|
||||
adapterType: optionalAgentAdapterTypeSchema,
|
||||
capabilities: z.string().max(4000).optional().nullable(),
|
||||
agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
// OpenClaw join compatibility fields accepted at top level.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
AGENT_ICON_NAMES,
|
||||
AGENT_ROLES,
|
||||
AGENT_STATUSES,
|
||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
} from "../constants.js";
|
||||
import { agentAdapterTypeSchema } from "../adapter-type.js";
|
||||
import { envConfigSchema } from "./secret.js";
|
||||
|
||||
export const agentPermissionsSchema = z.object({
|
||||
@@ -52,7 +52,7 @@ export const createAgentSchema = z.object({
|
||||
reportsTo: z.string().uuid().optional().nullable(),
|
||||
capabilities: z.string().optional().nullable(),
|
||||
desiredSkills: z.array(z.string().min(1)).optional(),
|
||||
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"),
|
||||
adapterType: agentAdapterTypeSchema,
|
||||
adapterConfig: adapterConfigSchema.optional().default({}),
|
||||
runtimeConfig: z.record(z.unknown()).optional().default({}),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# v2026.403.0
|
||||
|
||||
> Released: 2026-04-03
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Inbox overhaul** — New "Mine" inbox tab with mail-client keyboard shortcuts (j/k navigation, a/y archive, o open), swipe-to-archive, "Mark all as read" button, operator search with keyboard controls, and a "Today" divider. Read/dismissed state now extends to all inbox item types. ([#2072](https://github.com/paperclipai/paperclip/pull/2072), [#2540](https://github.com/paperclipai/paperclip/pull/2540))
|
||||
- **Feedback and evals** — Thumbs-up/down feedback capture flow with voting UI, feedback modal styling, and run link placement in the feedback row. ([#2529](https://github.com/paperclipai/paperclip/pull/2529))
|
||||
- **Document revisions** — Issue document revision history with a restore flow, replay-safe migrations, and revision tracking API. ([#2317](https://github.com/paperclipai/paperclip/pull/2317))
|
||||
- **Telemetry** — Anonymized App-side telemetry. Disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` ([#2527](https://github.com/paperclipai/paperclip/pull/2527))
|
||||
- **Execution workspaces (EXPERIMENTAL)** — Full workspace lifecycle management for agent runs: workspace-aware routine runs, execution workspace detail pages with linked issues, runtime controls (start/stop), close readiness checks, and follow-up issue workspace inheritance. Project workspaces get their own detail pages and a dedicated tab on the project view. ([#2074](https://github.com/paperclipai/paperclip/pull/2074), [#2203](https://github.com/paperclipai/paperclip/pull/2203))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Comment interrupts** — New interrupt support for issue comments with queued comment thread UX.
|
||||
- **Docker improvements** — Improved base image organization, host UID/GID mapping for volume mounts, and Docker file structure. ([#2407](https://github.com/paperclipai/paperclip/pull/2407), [#1923](https://github.com/paperclipai/paperclip/pull/1923), @radiusred)
|
||||
- **Optimistic comments** — Comments render instantly with optimistic IDs while the server confirms; draft clearing is fixed for a smoother composing experience.
|
||||
- **GitHub Enterprise URL support** — Skill and company imports now accept GitHub Enterprise URLs with hardened GHE URL detection and shared GitHub helpers. ([#2449](https://github.com/paperclipai/paperclip/pull/2449), @statxc)
|
||||
- **Gemini local adapter** — Added `gemini_local` to the adapter types validation enum so Gemini agents no longer fail validation. ([#2430](https://github.com/paperclipai/paperclip/pull/2430), @bittoby)
|
||||
- **Routines skill** — New `paperclip-routines` skill with documentation moved into Paperclip references. Routine runs now support workspace awareness and variables. ([#2414](https://github.com/paperclipai/paperclip/pull/2414), @aronprins)
|
||||
- **GPT-5.4 and xhigh effort** — Added GPT-5.4 model fallback and xhigh effort options for OpenAI-based adapters. ([#112](https://github.com/paperclipai/paperclip/pull/112), @kevmok)
|
||||
- **Commit metrics** — New Paperclip commit metrics script with filtered exports and edge case handling.
|
||||
- **CLI onboarding** — Onboarding reruns now preserve existing config; exported tsx CLI entrypoint for cleaner startup. ([#2071](https://github.com/paperclipai/paperclip/pull/2071))
|
||||
- **Board delegation guide** — New documentation for board-operator delegation patterns. ([#1889](https://github.com/paperclipai/paperclip/pull/1889))
|
||||
- **Agent capabilities in org chart** — Agent capabilities field now renders on org chart cards. ([#2349](https://github.com/paperclipai/paperclip/pull/2349))
|
||||
- **PR template updates** — Added Model Used section to PR template; CONTRIBUTING.md now requires PR template, Greptile 5/5, and tests. ([#2552](https://github.com/paperclipai/paperclip/pull/2552), [#2618](https://github.com/paperclipai/paperclip/pull/2618))
|
||||
- **Hermes adapter upgrade** — Upgraded hermes-paperclip-adapter with UI adapter and skills support, plus detectModel improvements.
|
||||
- **Markdown editor monospace** — Agent instruction file editors now use monospace font. ([#2620](https://github.com/paperclipai/paperclip/pull/2620))
|
||||
- **Markdown link styling** — Links in markdown now render with underline and pointer cursor.
|
||||
- **@-mention autocomplete** — Mention autocomplete in project descriptions now renders via portal to prevent overflow clipping.
|
||||
- **Skipped wakeup messages** — Agent detail view now surfaces skipped wakeup messages for better observability.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Inbox ordering** — Self-touched issues no longer sink to the bottom of the inbox. ([#2144](https://github.com/paperclipai/paperclip/pull/2144))
|
||||
- **Env var type switching** — Switching an env var from Plain to Secret no longer loses the value; dropdown snap-back when switching is fixed. ([#2327](https://github.com/paperclipai/paperclip/pull/2327), @radiusred)
|
||||
- **Adapter type switching** — Adapter-agnostic keys are now preserved when changing adapter type.
|
||||
- **Project slug collisions** — Non-ASCII project names no longer produce duplicate slugs; a short UUID suffix is appended. ([#2328](https://github.com/paperclipai/paperclip/pull/2328), @bittoby)
|
||||
- **Codex RPC spawn error** — Fixed CodexRpcClient crash on ENOENT when spawning Codex. ([#2048](https://github.com/paperclipai/paperclip/pull/2048), @remdev)
|
||||
- **Heartbeat session reuse** — Fixed stale session reuse across heartbeat runs. ([#2065](https://github.com/paperclipai/paperclip/pull/2065), @edimuj)
|
||||
- **Vite HMR with reverse proxy** — Fixed WebSocket HMR connections behind reverse proxies and added StrictMode guard. ([#2171](https://github.com/paperclipai/paperclip/pull/2171))
|
||||
- **Copy button fallback** — Copy-to-clipboard now works in non-secure (HTTP) contexts. ([#2472](https://github.com/paperclipai/paperclip/pull/2472))
|
||||
- **Worktree default branch** — Worktree creation auto-detects the default branch when baseRef is not configured. ([#2463](https://github.com/paperclipai/paperclip/pull/2463))
|
||||
- **Session continuity** — Timer and heartbeat wakes now preserve session continuity.
|
||||
- **Worktree isolation** — Fixed worktree provision isolation, runtime recovery, and sibling port collisions.
|
||||
- **Cursor adapter auth** — Cursor adapter now checks native auth before warning about missing API key.
|
||||
- **Codex skill injection** — Fixed skill injection to use effective `$CODEX_HOME/skills/` instead of cwd.
|
||||
- **OpenCode config pollution** — Prevented `opencode.json` config pollution in workspace directories.
|
||||
- **Pi adapter** — Fixed Pi local adapter execution, transcript parsing, and model detection from stderr.
|
||||
- **x-forwarded-host origin check** — Board mutation origin check now includes x-forwarded-host header.
|
||||
- **Health DB probe** — Fixed database connectivity health check probe.
|
||||
- **Issue breadcrumb routing** — Hardened issue breadcrumb source routing.
|
||||
- **Instructions tab width** — Removed max-w-6xl constraint from instructions tab for full-width content. ([#2621](https://github.com/paperclipai/paperclip/pull/2621))
|
||||
- **Shell fallback on Windows** — Uses `sh` instead of `/bin/sh` as shell fallback on Windows. ([#891](https://github.com/paperclipai/paperclip/pull/891))
|
||||
- **Feedback migration** — Made feedback migration replay-safe after rebase.
|
||||
- **Issue detail polish** — Polished issue detail timelines and attachments display.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Four new database migrations (`0045`–`0048`) will run automatically on startup. These migrations add workspace lifecycle columns, routine variables, feedback tables, and document revision tracking. All migrations are additive — no existing data is modified.
|
||||
|
||||
If you use execution workspaces, note that follow-up issues now automatically inherit workspace linkage from their parent. For non-child follow-ups tied to the same workspace, set `inheritExecutionWorkspaceFromIssueId` explicitly when creating the issue.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@aronprins, @bittoby, @cryppadotta, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai
|
||||
@@ -335,6 +335,80 @@ disable_seeded_routines() {
|
||||
|
||||
disable_seeded_routines
|
||||
|
||||
list_base_node_modules_paths() {
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
-maxdepth 4 \
|
||||
-type d \
|
||||
-name node_modules \
|
||||
! -path './.git/*' \
|
||||
! -path './.paperclip/*' \
|
||||
| sed 's#^\./##'
|
||||
}
|
||||
|
||||
if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; then
|
||||
needs_install=0
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
|
||||
if [[ -L "$target_path" || ! -e "$target_path" ]]; then
|
||||
needs_install=1
|
||||
break
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
if [[ "$needs_install" -eq 1 ]]; then
|
||||
backup_suffix=".paperclip-backup-$BASHPID"
|
||||
moved_symlink_paths=()
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
if [[ -L "$target_path" ]]; then
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
rm -rf "$backup_path"
|
||||
mv "$target_path" "$backup_path"
|
||||
moved_symlink_paths+=("$relative_path")
|
||||
fi
|
||||
done < <(list_base_node_modules_paths)
|
||||
|
||||
restore_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] || continue
|
||||
rm -rf "$target_path"
|
||||
mv "$backup_path" "$target_path"
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_moved_symlinks() {
|
||||
local relative_path target_path backup_path
|
||||
for relative_path in "${moved_symlink_paths[@]}"; do
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
backup_path="${target_path}${backup_suffix}"
|
||||
[[ -L "$backup_path" ]] && rm "$backup_path"
|
||||
done
|
||||
}
|
||||
|
||||
(
|
||||
cd "$worktree_cwd"
|
||||
pnpm install --frozen-lockfile
|
||||
) || {
|
||||
restore_moved_symlinks
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup_moved_symlinks
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
source_path="$base_cwd/$relative_path"
|
||||
@@ -346,13 +420,5 @@ while IFS= read -r relative_path; do
|
||||
mkdir -p "$(dirname "$target_path")"
|
||||
ln -s "$source_path" "$target_path"
|
||||
done < <(
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
-maxdepth 3 \
|
||||
-type d \
|
||||
-name node_modules \
|
||||
! -path './.git/*' \
|
||||
! -path './.paperclip/*' \
|
||||
| sed 's#^\./##'
|
||||
list_base_node_modules_paths
|
||||
)
|
||||
|
||||
@@ -123,11 +123,14 @@ function setVersion(version) {
|
||||
`.version("${version}")`,
|
||||
);
|
||||
|
||||
if (cliEntry === nextCliEntry) {
|
||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||
if (cliEntry !== nextCliEntry) {
|
||||
writeFileSync(cliEntryPath, nextCliEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(cliEntryPath, nextCliEntry);
|
||||
if (!cliEntry.includes(".version(cliVersion)")) {
|
||||
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
|
||||
}
|
||||
}
|
||||
|
||||
function listPackages() {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Screenshot utility for Paperclip UI.
|
||||
*
|
||||
* Reads the board token from ~/.paperclip/auth.json and injects it as a
|
||||
* Bearer header so Playwright can access authenticated pages.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/screenshot.cjs <url-or-path> [output.png] [--width 1280] [--height 800] [--wait 2000]
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/screenshot.cjs /PAPA/agents/cto/instructions /tmp/shot.png
|
||||
* node scripts/screenshot.cjs http://localhost:5173/PAPA/agents/cto/instructions
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
// --- CLI args -----------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
function flag(name, fallback) {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const val = args.splice(i, 2)[1];
|
||||
return Number.isNaN(Number(val)) ? fallback : Number(val);
|
||||
}
|
||||
const width = flag("width", 1280);
|
||||
const height = flag("height", 800);
|
||||
const waitMs = flag("wait", 2000);
|
||||
|
||||
const rawUrl = args[0];
|
||||
const outPath = args[1] || "/tmp/paperclip-screenshot.png";
|
||||
|
||||
if (!rawUrl) {
|
||||
console.error("Usage: node scripts/screenshot.cjs <url-or-path> [output.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Auth ----------------------------------------------------------------
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const creds = auth.credentials || {};
|
||||
const entry = Object.values(creds)[0];
|
||||
if (entry && entry.token && entry.apiBase) return { token: entry.token, apiBase: entry.apiBase };
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = loadBoardToken();
|
||||
if (!cred) {
|
||||
console.error("No board token found in ~/.paperclip/auth.json");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve URL — if it starts with / treat as path relative to apiBase
|
||||
const url = rawUrl.startsWith("http") ? rawUrl : `${cred.apiBase}${rawUrl}`;
|
||||
|
||||
// Validate URL before launching browser
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
// --- Screenshot ----------------------------------------------------------
|
||||
(async () => {
|
||||
const { chromium } = require("playwright");
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
// Scope the auth header to the Paperclip origin only
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${cred.token}` },
|
||||
});
|
||||
});
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Screenshot failed: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
findServerAdapter,
|
||||
listAdapterModels,
|
||||
registerServerAdapter,
|
||||
requireServerAdapter,
|
||||
unregisterServerAdapter,
|
||||
} from "../adapters/index.js";
|
||||
import { setOverridePaused } from "../adapters/registry.js";
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
}),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "external_test",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
models: [{ id: "external-model", label: "External Model" }],
|
||||
supportsLocalAgentJwt: false,
|
||||
};
|
||||
|
||||
describe("server adapter registry", () => {
|
||||
beforeEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
unregisterServerAdapter("claude_local");
|
||||
setOverridePaused("claude_local", false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
unregisterServerAdapter("claude_local");
|
||||
setOverridePaused("claude_local", false);
|
||||
});
|
||||
|
||||
it("registers external adapters and exposes them through lookup helpers", async () => {
|
||||
expect(findServerAdapter("external_test")).toBeNull();
|
||||
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
expect(requireServerAdapter("external_test")).toBe(externalAdapter);
|
||||
expect(await listAdapterModels("external_test")).toEqual([
|
||||
{ id: "external-model", label: "External Model" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes external adapters when unregistered", () => {
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
unregisterServerAdapter("external_test");
|
||||
|
||||
expect(findServerAdapter("external_test")).toBeNull();
|
||||
expect(() => requireServerAdapter("external_test")).toThrow(
|
||||
"Unknown adapter type: external_test",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows external plugin to override a built-in adapter type", () => {
|
||||
// claude_local is always built-in
|
||||
const builtIn = findServerAdapter("claude_local");
|
||||
expect(builtIn).not.toBeNull();
|
||||
|
||||
const plugin: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
}),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "claude_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
||||
supportsLocalAgentJwt: false,
|
||||
};
|
||||
|
||||
registerServerAdapter(plugin);
|
||||
|
||||
// Plugin wins
|
||||
const resolved = requireServerAdapter("claude_local");
|
||||
expect(resolved).toBe(plugin);
|
||||
expect(resolved.models).toEqual([
|
||||
{ id: "plugin-model", label: "Plugin Override" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches active adapter behavior back to the builtin when an override is paused", async () => {
|
||||
const builtIn = findServerAdapter("claude_local");
|
||||
expect(builtIn).not.toBeNull();
|
||||
|
||||
const detectModel = vi.fn(async () => ({
|
||||
model: "plugin-model",
|
||||
provider: "plugin-provider",
|
||||
source: "plugin-source",
|
||||
}));
|
||||
const plugin: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
}),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "claude_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
models: [{ id: "plugin-model", label: "Plugin Override" }],
|
||||
detectModel,
|
||||
supportsLocalAgentJwt: false,
|
||||
};
|
||||
|
||||
registerServerAdapter(plugin);
|
||||
|
||||
expect(findActiveServerAdapter("claude_local")).toBe(plugin);
|
||||
expect(await listAdapterModels("claude_local")).toEqual([
|
||||
{ id: "plugin-model", label: "Plugin Override" },
|
||||
]);
|
||||
expect(await detectAdapterModel("claude_local")).toMatchObject({
|
||||
model: "plugin-model",
|
||||
provider: "plugin-provider",
|
||||
});
|
||||
|
||||
expect(setOverridePaused("claude_local", true)).toBe(true);
|
||||
|
||||
expect(findActiveServerAdapter("claude_local")).not.toBe(plugin);
|
||||
expect(await listAdapterModels("claude_local")).toEqual(builtIn?.models ?? []);
|
||||
expect(await detectAdapterModel("claude_local")).toBeNull();
|
||||
expect(detectModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
import { setOverridePaused } from "../adapters/registry.js";
|
||||
import { adapterRoutes } from "../routes/adapters.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const overridingConfigSchemaAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "claude_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
getConfigSchema: async () => ({
|
||||
version: 1,
|
||||
fields: [
|
||||
{
|
||||
key: "mode",
|
||||
type: "text",
|
||||
label: "Mode",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: [],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", adapterRoutes());
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("adapter routes", () => {
|
||||
beforeEach(() => {
|
||||
setOverridePaused("claude_local", false);
|
||||
registerServerAdapter(overridingConfigSchemaAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setOverridePaused("claude_local", false);
|
||||
unregisterServerAdapter("claude_local");
|
||||
});
|
||||
|
||||
it("uses the active adapter when resolving config schema for a paused builtin override", async () => {
|
||||
const app = createApp();
|
||||
|
||||
const active = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||
expect(active.status, JSON.stringify(active.body)).toBe(200);
|
||||
expect(active.body).toMatchObject({
|
||||
fields: [{ key: "mode" }],
|
||||
});
|
||||
|
||||
const paused = await request(app)
|
||||
.patch("/api/adapters/claude_local/override")
|
||||
.send({ paused: true });
|
||||
expect(paused.status, JSON.stringify(paused.body)).toBe(200);
|
||||
|
||||
const builtin = await request(app).get("/api/adapters/claude_local/config-schema");
|
||||
expect(builtin.status, JSON.stringify(builtin.body)).toBe(404);
|
||||
expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentRoutes } from "../routes/agents.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
setPrincipalPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
listRuntimeSkillEntries: vi.fn(),
|
||||
resolveRequestedSkillKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
materializeManagedBundle: vi.fn(),
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
updateBundle: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
exportFiles: vi.fn(),
|
||||
ensureManagedBundle: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBudgetService = vi.hoisted(() => ({
|
||||
upsertPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
cancelActiveForAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
issueService: () => ({}),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
type: "external_test",
|
||||
execute: async () => ({ exitCode: 0, signal: null, timedOut: false }),
|
||||
testEnvironment: async () => ({
|
||||
adapterType: "external_test",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
};
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent routes adapter validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unregisterServerAdapter("external_test");
|
||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockResolvedValue([]);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: String(input.name ?? "Agent"),
|
||||
urlKey: "agent",
|
||||
role: String(input.role ?? "general"),
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: String(input.adapterType ?? "process"),
|
||||
adapterConfig: (input.adapterConfig as Record<string, unknown> | undefined) ?? {},
|
||||
runtimeConfig: (input.runtimeConfig as Record<string, unknown> | undefined) ?? {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterServerAdapter("external_test");
|
||||
});
|
||||
|
||||
it("creates agents for dynamically registered external adapter types", async () => {
|
||||
registerServerAdapter(externalAdapter);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "External Agent",
|
||||
adapterType: "external_test",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.adapterType).toBe("external_test");
|
||||
});
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Missing Adapter",
|
||||
adapterType: "missing_adapter",
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error ?? res.body.message ?? "")).toContain("Unknown adapter type: missing_adapter");
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ vi.mock("../services/index.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
findServerAdapter: vi.fn((_type: string) => ({ type: _type })),
|
||||
listAdapterModels: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -51,12 +51,23 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockAdapter = vi.hoisted(() => ({
|
||||
listSkills: vi.fn(),
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
@@ -75,7 +86,9 @@ vi.mock("../services/index.js", () => ({
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDb(requireBoardApprovalForNewAgents = false) {
|
||||
@@ -132,6 +145,7 @@ function makeAgent(adapterType: string) {
|
||||
describe("agent skill routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockAgentService.resolveByReference.mockResolvedValue({
|
||||
ambiguous: false,
|
||||
agent: makeAgent("claude_local"),
|
||||
@@ -330,6 +344,9 @@ describe("agent skill routes", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
|
||||
agentRole: "engineer",
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes a managed AGENTS.md for directly created local agents", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
codexHome: process.env.CODEX_HOME || null,
|
||||
paperclipWakePayloadJson: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON || null,
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
@@ -32,6 +33,7 @@ type CapturePayload = {
|
||||
argv: string[];
|
||||
prompt: string;
|
||||
codexHome: string | null;
|
||||
paperclipWakePayloadJson: string | null;
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
@@ -259,6 +261,225 @@ describe("codex execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("injects structured Paperclip wake payloads into env and prompt", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-1",
|
||||
body: "First comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:00.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 2,
|
||||
includedCount: 2,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_commented",
|
||||
latestCommentId: "comment-2",
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Treat this wake payload as the highest-priority change for the current heartbeat.");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain(
|
||||
"acknowledge the latest comment and explain how it changes your next action.",
|
||||
);
|
||||
expect(capture.prompt).toContain("First comment");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
const instructionsPath = path.join(root, "AGENTS.md");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8");
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
let invocationNotes: string[] = [];
|
||||
let promptMetrics: Record<string, number> = {};
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-resume-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: {
|
||||
sessionId: "codex-session-1",
|
||||
cwd: workspace,
|
||||
},
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
instructionsFilePath: instructionsPath,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 1,
|
||||
includedCount: 1,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
invocationNotes = meta.commandNotes ?? [];
|
||||
promptMetrics = meta.promptMetrics ?? {};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
expect(capture.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
expect(capture.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain(
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
);
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
||||
@@ -18,6 +18,22 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackSkillImported: mockTrackSkillImported,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -41,6 +57,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
describe("company skill mutation permissions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [],
|
||||
warnings: [],
|
||||
@@ -68,6 +85,140 @@ describe("company skill mutation permissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks public GitHub skill imports with an explicit skill reference", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "vercel-labs/agent-browser/find-skills",
|
||||
slug: "find-skills",
|
||||
name: "Find Skills",
|
||||
description: null,
|
||||
markdown: "# Find Skills",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/vercel-labs/agent-browser",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: {
|
||||
hostname: "github.com",
|
||||
owner: "vercel-labs",
|
||||
repo: "agent-browser",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: "vercel-labs/agent-browser/find-skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose a skill reference for non-public skill imports", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "private-skill",
|
||||
slug: "private-skill",
|
||||
name: "Private Skill",
|
||||
description: null,
|
||||
markdown: "# Private Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://ghe.example.com/acme/private-skill",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: {
|
||||
hostname: "ghe.example.com",
|
||||
owner: "acme",
|
||||
repo: "private-skill",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://ghe.example.com/acme/private-skill" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose a skill reference when GitHub metadata is missing", async () => {
|
||||
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||
imported: [
|
||||
{
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "unknown/private-skill",
|
||||
slug: "private-skill",
|
||||
name: "Private Skill",
|
||||
description: null,
|
||||
markdown: "# Private Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/acme/private-skill",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [],
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/acme/private-skill" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||
sourceType: "github",
|
||||
skillRef: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
@@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||
"git_branch_delete",
|
||||
]));
|
||||
}, 20_000);
|
||||
|
||||
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const olderServiceId = randomUUID();
|
||||
const currentServiceId = randomUUID();
|
||||
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
|
||||
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
|
||||
const runningAt = new Date("2026-04-04T17:10:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Workspaces",
|
||||
status: "in_progress",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
metadata: {
|
||||
runtimeConfig: {
|
||||
desiredState: "running",
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "shared_workspace",
|
||||
strategyType: "project_primary",
|
||||
name: "Shared workspace",
|
||||
status: "active",
|
||||
providerType: "local_fs",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values([
|
||||
{
|
||||
id: olderServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
scopeType: "project_workspace",
|
||||
scopeId: projectWorkspaceId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "stopped",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
port: 49195,
|
||||
url: "http://127.0.0.1:49195",
|
||||
provider: "local_process",
|
||||
providerRef: "11111",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: stoppedAt,
|
||||
startedAt,
|
||||
stoppedAt,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "unknown",
|
||||
createdAt: startedAt,
|
||||
updatedAt: stoppedAt,
|
||||
},
|
||||
{
|
||||
id: currentServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
scopeType: "project_workspace",
|
||||
scopeId: projectWorkspaceId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
port: 49222,
|
||||
url: "http://127.0.0.1:49222",
|
||||
provider: "local_process",
|
||||
providerRef: "22222",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: runningAt,
|
||||
startedAt: runningAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "healthy",
|
||||
createdAt: runningAt,
|
||||
updatedAt: runningAt,
|
||||
},
|
||||
]);
|
||||
|
||||
const workspace = await svc.getById(executionWorkspaceId);
|
||||
const listed = await svc.list(companyId, { projectId });
|
||||
|
||||
expect(workspace?.runtimeServices).toHaveLength(1);
|
||||
expect(workspace?.runtimeServices?.[0]).toMatchObject({
|
||||
id: currentServiceId,
|
||||
status: "running",
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
url: "http://127.0.0.1:49222",
|
||||
});
|
||||
expect(listed[0]?.runtimeServices).toHaveLength(1);
|
||||
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +187,11 @@ describe("feedbackService.saveIssueVote", () => {
|
||||
const targetCommentId = randomUUID();
|
||||
const earlierCommentId = randomUUID();
|
||||
const laterCommentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
// Use a deterministic UUID whose hyphen-separated segments cannot be
|
||||
// mistaken for a phone number by the PII redactor's phone regex.
|
||||
// Random UUIDs occasionally produce digit pairs like "4880-8614" that
|
||||
// cross segment boundaries and match the phone pattern.
|
||||
const runId = "abcde123-face-beef-cafe-abcdef654321";
|
||||
const instructionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-feedback-instructions-"));
|
||||
tempDirs.push(instructionsDir);
|
||||
const instructionsPath = path.join(instructionsDir, "AGENTS.md");
|
||||
@@ -1065,6 +1069,73 @@ describe("feedbackService.saveIssueVote", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can flush a single shared trace immediately by trace id", async () => {
|
||||
const { companyId, issueId, commentId: firstCommentId } = await seedIssueWithAgentComment();
|
||||
const secondCommentId = randomUUID();
|
||||
const agentId = await db
|
||||
.select({ authorAgentId: issueComments.authorAgentId })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.id, firstCommentId))
|
||||
.then((rows) => rows[0]?.authorAgentId ?? null);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
id: secondCommentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
body: "Second AI generated update",
|
||||
});
|
||||
|
||||
const uploadTraceBundle = vi.fn().mockResolvedValue({
|
||||
objectKey: `feedback-traces/${companyId}/2026/04/01/test-trace.json`,
|
||||
});
|
||||
const flushingSvc = feedbackService(db, {
|
||||
shareClient: {
|
||||
uploadTraceBundle,
|
||||
},
|
||||
});
|
||||
|
||||
const first = await flushingSvc.saveIssueVote({
|
||||
issueId,
|
||||
targetType: "issue_comment",
|
||||
targetId: firstCommentId,
|
||||
vote: "up",
|
||||
authorUserId: "user-1",
|
||||
allowSharing: true,
|
||||
});
|
||||
await flushingSvc.saveIssueVote({
|
||||
issueId,
|
||||
targetType: "issue_comment",
|
||||
targetId: secondCommentId,
|
||||
vote: "up",
|
||||
authorUserId: "user-1",
|
||||
allowSharing: true,
|
||||
});
|
||||
|
||||
const flushResult = await flushingSvc.flushPendingFeedbackTraces({
|
||||
companyId,
|
||||
traceId: first.traceId ?? undefined,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(flushResult).toMatchObject({
|
||||
attempted: 1,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
||||
|
||||
const traces = await flushingSvc.listFeedbackTraces({
|
||||
companyId,
|
||||
issueId,
|
||||
includePayload: true,
|
||||
});
|
||||
const firstTrace = traces.find((trace) => trace.targetId === firstCommentId);
|
||||
const secondTrace = traces.find((trace) => trace.targetId === secondCommentId);
|
||||
expect(firstTrace?.status).toBe("sent");
|
||||
expect(secondTrace?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("marks pending shared traces as failed when remote export upload fails", async () => {
|
||||
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
|
||||
const uploadTraceBundle = vi.fn().mockRejectedValue(new Error("telemetry unavailable"));
|
||||
@@ -1102,4 +1173,39 @@ describe("feedbackService.saveIssueVote", () => {
|
||||
expect(traces[0]?.exportedAt).toBeNull();
|
||||
expect(uploadTraceBundle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks pending shared traces as failed when no feedback export backend is configured", async () => {
|
||||
const { companyId, issueId, commentId } = await seedIssueWithAgentComment();
|
||||
|
||||
const result = await svc.saveIssueVote({
|
||||
issueId,
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
vote: "up",
|
||||
authorUserId: "user-1",
|
||||
allowSharing: true,
|
||||
});
|
||||
|
||||
const flushResult = await svc.flushPendingFeedbackTraces({
|
||||
companyId,
|
||||
traceId: result.traceId ?? undefined,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(flushResult).toMatchObject({
|
||||
attempted: 1,
|
||||
sent: 0,
|
||||
failed: 1,
|
||||
});
|
||||
|
||||
const traces = await svc.listFeedbackTraces({
|
||||
companyId,
|
||||
issueId,
|
||||
includePayload: true,
|
||||
});
|
||||
expect(traces[0]?.status).toBe("failed");
|
||||
expect(traces[0]?.attemptCount).toBe(1);
|
||||
expect(traces[0]?.failureReason).toBe("Feedback export backend is not configured");
|
||||
expect(traces[0]?.exportedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createFeedbackTraceShareClientFromConfig } from "../services/feedback-share-client.js";
|
||||
|
||||
describe("feedback trace share client", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ objectKey: "feedback-traces/test.json" }),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("defaults to telemetry.paperclip.ing when no backend url is configured", async () => {
|
||||
const client = createFeedbackTraceShareClientFromConfig({
|
||||
feedbackExportBackendUrl: undefined,
|
||||
feedbackExportBackendToken: undefined,
|
||||
});
|
||||
|
||||
await client.uploadTraceBundle({
|
||||
traceId: "trace-1",
|
||||
exportId: "export-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueIdentifier: "PAP-1",
|
||||
adapterType: "codex_local",
|
||||
captureStatus: "full",
|
||||
notes: [],
|
||||
envelope: {},
|
||||
surface: null,
|
||||
paperclipRun: null,
|
||||
rawAdapterTrace: null,
|
||||
normalizedAdapterTrace: null,
|
||||
privacy: null,
|
||||
integrity: {},
|
||||
files: [],
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://telemetry.paperclip.ing/feedback-traces",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps the feedback trace payload as gzip+base64 json before upload", async () => {
|
||||
const client = createFeedbackTraceShareClientFromConfig({
|
||||
feedbackExportBackendUrl: "https://telemetry.paperclip.ing",
|
||||
feedbackExportBackendToken: "test-token",
|
||||
});
|
||||
|
||||
await client.uploadTraceBundle({
|
||||
traceId: "trace-1",
|
||||
exportId: "export-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueIdentifier: "PAP-1",
|
||||
adapterType: "codex_local",
|
||||
captureStatus: "full",
|
||||
notes: [],
|
||||
envelope: { hello: "world" },
|
||||
surface: null,
|
||||
paperclipRun: null,
|
||||
rawAdapterTrace: null,
|
||||
normalizedAdapterTrace: null,
|
||||
privacy: null,
|
||||
integrity: {},
|
||||
files: [],
|
||||
});
|
||||
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
expect(call?.[0]).toBe("https://telemetry.paperclip.ing/feedback-traces");
|
||||
expect(call?.[1]).toMatchObject({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(String(call?.[1]?.body ?? "{}")) as {
|
||||
encoding?: string;
|
||||
payload?: string;
|
||||
};
|
||||
expect(body.encoding).toBe("gzip+base64+json");
|
||||
expect(typeof body.payload).toBe("string");
|
||||
|
||||
const decoded = gunzipSync(Buffer.from(body.payload ?? "", "base64")).toString("utf8");
|
||||
const parsed = JSON.parse(decoded) as {
|
||||
objectKey: string;
|
||||
bundle: { envelope: { hello: string } };
|
||||
};
|
||||
expect(parsed.objectKey).toContain("feedback-traces/company-1/");
|
||||
expect(parsed.objectKey.endsWith("/export-1.json")).toBe(true);
|
||||
expect(parsed.bundle.envelope).toEqual({ hello: "world" });
|
||||
});
|
||||
});
|
||||
@@ -168,4 +168,101 @@ describe("gemini execute", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-resume",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Coder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: "gemini-session-1",
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "gemini-2.5-pro",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-1",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 1,
|
||||
includedCount: 1,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
const promptFlagIndex = capture.argv.indexOf("--prompt");
|
||||
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
|
||||
expect(capture.argv).toContain("--resume");
|
||||
expect(capture.argv).toContain("gemini-session-1");
|
||||
expect(promptArg).toContain("## Paperclip Resume Delta");
|
||||
expect(promptArg).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(promptArg).toContain("Second comment");
|
||||
expect(promptArg).not.toContain("Follow the paperclip heartbeat.");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
applyPendingMigrations,
|
||||
companies,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const mod = await import("embedded-postgres");
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startTempDatabase() {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-comment-wake-"));
|
||||
const port = await getAvailablePort();
|
||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
return { connectionString, instance, dataDir };
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await condition()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
async function createControlledGatewayServer() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const agentPayloads: Array<Record<string, unknown>> = [];
|
||||
let firstWaitRelease: (() => void) | null = null;
|
||||
let firstWaitGate = new Promise<void>((resolve) => {
|
||||
firstWaitRelease = resolve;
|
||||
});
|
||||
let waitCount = 0;
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
socket.on("message", async (raw) => {
|
||||
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||
const frame = JSON.parse(text) as {
|
||||
type: string;
|
||||
id: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (frame.type !== "req") return;
|
||||
|
||||
if (frame.method === "connect") {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
server: { version: "test", connId: "conn-1" },
|
||||
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
|
||||
snapshot: { version: 1, ts: Date.now() },
|
||||
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent") {
|
||||
agentPayloads.push((frame.params ?? {}) as Record<string, unknown>);
|
||||
const runId =
|
||||
typeof frame.params?.idempotencyKey === "string"
|
||||
? frame.params.idempotencyKey
|
||||
: `run-${agentPayloads.length}`;
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId,
|
||||
status: "accepted",
|
||||
acceptedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.method === "agent.wait") {
|
||||
waitCount += 1;
|
||||
if (waitCount === 1) {
|
||||
await firstWaitGate;
|
||||
}
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to resolve test server address");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}`,
|
||||
getAgentPayloads: () => agentPayloads,
|
||||
releaseFirstWait: () => {
|
||||
firstWaitRelease?.();
|
||||
firstWaitRelease = null;
|
||||
firstWaitGate = Promise.resolve();
|
||||
},
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("heartbeat comment wake batching", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
let dataDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startTempDatabase();
|
||||
db = createDb(started.connectionString);
|
||||
instance = started.instance;
|
||||
dataDir = started.dataDir;
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await instance?.stop();
|
||||
if (dataDir) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Gateway Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Batch wake comments",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
});
|
||||
|
||||
const comment1 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "First comment",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment1.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment1.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
const comment2 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "Second comment",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
const comment3 = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "user-1",
|
||||
body: "Third comment",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const secondRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment2.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment2.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
const thirdRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId, commentId: comment3.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment3.id,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(secondRun).toBeNull();
|
||||
expect(thirdRun).toBeNull();
|
||||
|
||||
await waitFor(async () => {
|
||||
const deferred = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(deferred);
|
||||
});
|
||||
|
||||
const deferredWake = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, agentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
const deferredContext = (deferredWake?.payload as Record<string, unknown> | null)?._paperclipWakeContext as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(deferredContext?.wakeCommentIds).toEqual([comment2.id, comment3.id]);
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 2);
|
||||
await waitFor(async () => {
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
|
||||
return runs.length === 2 && runs.every((run) => run.status === "succeeded");
|
||||
});
|
||||
|
||||
const secondPayload = gateway.getAgentPayloads()[1] ?? {};
|
||||
expect(secondPayload.paperclip).toMatchObject({
|
||||
wake: {
|
||||
commentIds: [comment2.id, comment3.id],
|
||||
latestCommentId: comment3.id,
|
||||
},
|
||||
});
|
||||
expect(String(secondPayload.message ?? "")).toContain("Second comment");
|
||||
expect(String(secondPayload.message ?? "")).toContain("Third comment");
|
||||
expect(String(secondPayload.message ?? "")).not.toContain("First comment");
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
|
||||
import {
|
||||
summarizeHeartbeatRunResultJson,
|
||||
buildHeartbeatRunIssueComment,
|
||||
} from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
it("truncates text fields and preserves cost aliases", () => {
|
||||
@@ -31,3 +34,24 @@ describe("summarizeHeartbeatRunResultJson", () => {
|
||||
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHeartbeatRunIssueComment", () => {
|
||||
it("uses the final summary text for issue comments on successful runs", () => {
|
||||
const comment = buildHeartbeatRunIssueComment({
|
||||
summary: "## Summary\n\n- fixed deploy config\n- posted issue update",
|
||||
});
|
||||
|
||||
expect(comment).toContain("## Summary");
|
||||
expect(comment).toContain("- fixed deploy config");
|
||||
expect(comment).not.toContain("Run summary");
|
||||
});
|
||||
|
||||
it("falls back to result or message when summary is missing", () => {
|
||||
expect(buildHeartbeatRunIssueComment({ result: "done" })).toBe("done");
|
||||
expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe("completed");
|
||||
});
|
||||
|
||||
it("returns null when there is no usable final text", () => {
|
||||
expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
buildRealizedExecutionWorkspaceFromPersisted,
|
||||
buildExplicitResumeSessionOverride,
|
||||
deriveTaskKeyWithHeartbeatFallback,
|
||||
extractWakeCommentIds,
|
||||
formatRuntimeWorkspaceWarningLog,
|
||||
mergeCoalescedContextSnapshot,
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
@@ -357,6 +359,32 @@ describe("deriveTaskKeyWithHeartbeatFallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment wake batching", () => {
|
||||
it("preserves ordered wake comment ids when coalescing queued follow-up wakes", () => {
|
||||
const merged = mergeCoalescedContextSnapshot(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-1",
|
||||
wakeCommentIds: ["comment-1"],
|
||||
paperclipWake: {
|
||||
latestCommentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
issueId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
wakeCommentId: "comment-2",
|
||||
},
|
||||
);
|
||||
|
||||
expect(extractWakeCommentIds(merged)).toEqual(["comment-1", "comment-2"]);
|
||||
expect(merged.commentId).toBe("comment-2");
|
||||
expect(merged.wakeCommentId).toBe("comment-2");
|
||||
expect(merged.paperclipWake).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExplicitResumeSessionOverride", () => {
|
||||
it("reuses saved task session params when they belong to the selected failed run", () => {
|
||||
const result = buildExplicitResumeSessionOverride({
|
||||
|
||||
@@ -4,14 +4,14 @@ import { notifyHireApproved } from "../services/hire-hook.js";
|
||||
|
||||
// Mock the registry so we control whether the adapter has onHireApproved and what it does.
|
||||
vi.mock("../adapters/registry.js", () => ({
|
||||
findServerAdapter: vi.fn(),
|
||||
findActiveServerAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity-log.js", () => ({
|
||||
logActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { findServerAdapter } = await import("../adapters/registry.js");
|
||||
const { findActiveServerAdapter } = await import("../adapters/registry.js");
|
||||
const { logActivity } = await import("../services/activity-log.js");
|
||||
|
||||
function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record<string, unknown> }): Db {
|
||||
@@ -39,7 +39,7 @@ afterEach(() => {
|
||||
|
||||
describe("notifyHireApproved", () => {
|
||||
it("writes success activity when adapter hook returns ok", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||
} as any);
|
||||
@@ -88,11 +88,11 @@ describe("notifyHireApproved", () => {
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).not.toHaveBeenCalled();
|
||||
expect(findActiveServerAdapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when adapter has no onHireApproved", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any);
|
||||
vi.mocked(findActiveServerAdapter).mockReturnValue({ type: "process" } as any);
|
||||
|
||||
const db = mockDbWithAgent({
|
||||
id: "a1",
|
||||
@@ -110,12 +110,12 @@ describe("notifyHireApproved", () => {
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(findServerAdapter).toHaveBeenCalledWith("process");
|
||||
expect(findActiveServerAdapter).toHaveBeenCalledWith("process");
|
||||
expect(logActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||
} as any);
|
||||
@@ -147,7 +147,7 @@ describe("notifyHireApproved", () => {
|
||||
});
|
||||
|
||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||
vi.mocked(findServerAdapter).mockReturnValue({
|
||||
vi.mocked(findActiveServerAdapter).mockReturnValue({
|
||||
type: "openclaw_gateway",
|
||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
} as any);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const issueId = "11111111-1111-4111-8111-111111111111";
|
||||
const closedWorkspaceId = "33333333-3333-4333-8333-333333333333";
|
||||
const nextWorkspaceId = "44444444-4444-4444-8444-444444444444";
|
||||
const agentId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({
|
||||
getDefaultCompanyGoal: vi.fn(async () => null),
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: issueId,
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1085",
|
||||
title: "Closed worktree issue",
|
||||
projectId: null,
|
||||
executionRunId: null,
|
||||
checkoutRunId: null,
|
||||
executionWorkspaceId: closedWorkspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClosedWorkspace() {
|
||||
return {
|
||||
id: closedWorkspaceId,
|
||||
name: "PAP-1085-fix-worktree-guard",
|
||||
mode: "isolated_workspace",
|
||||
status: "archived",
|
||||
closedAt: new Date("2026-04-04T17:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("closed isolated workspace issue routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
|
||||
});
|
||||
|
||||
it("rejects new issue comments when the linked isolated workspace is closed", async () => {
|
||||
const res = await request(createApp())
|
||||
.post(`/api/issues/${issueId}/comments`)
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("closed workspace");
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects comment updates when the linked isolated workspace is closed", async () => {
|
||||
const res = await request(createApp())
|
||||
.patch(`/api/issues/${issueId}`)
|
||||
.send({ comment: "hello" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("closed workspace");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects checkout when the linked isolated workspace is closed", async () => {
|
||||
const res = await request(createApp())
|
||||
.post(`/api/issues/${issueId}/checkout`)
|
||||
.send({
|
||||
agentId,
|
||||
expectedStatuses: ["todo", "backlog", "blocked"],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("closed workspace");
|
||||
expect(mockIssueService.checkout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows non-comment board updates so the issue can be moved to a new workspace", async () => {
|
||||
mockIssueService.update.mockResolvedValue({
|
||||
...makeIssue(),
|
||||
executionWorkspaceId: nextWorkspaceId,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch(`/api/issues/${issueId}`)
|
||||
.send({ executionWorkspaceId: nextWorkspaceId });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, { executionWorkspaceId: nextWorkspaceId });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,18 @@ const mockFeedbackService = vi.hoisted(() => ({
|
||||
saveIssueVote: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFeedbackExportService = vi.hoisted(() => ({
|
||||
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
@@ -42,12 +54,7 @@ vi.mock("../services/index.js", () => ({
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
@@ -63,7 +70,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use("/api", issueRoutes({} as any, {} as any, { feedbackExportService: mockFeedbackExportService }));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -73,6 +80,50 @@ describe("issue feedback trace routes", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("flushes a newly shared feedback trace immediately after saving the vote", async () => {
|
||||
const targetId = "11111111-1111-4111-8111-111111111111";
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
mockFeedbackService.saveIssueVote.mockResolvedValue({
|
||||
vote: {
|
||||
targetType: "issue_comment",
|
||||
targetId,
|
||||
vote: "up",
|
||||
reason: null,
|
||||
},
|
||||
traceId: "trace-1",
|
||||
consentEnabledNow: false,
|
||||
persistedSharingPreference: null,
|
||||
sharingEnabled: true,
|
||||
});
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/issue-1/feedback-votes")
|
||||
.send({
|
||||
targetType: "issue_comment",
|
||||
targetId,
|
||||
vote: "up",
|
||||
allowSharing: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockFeedbackExportService.flushPendingFeedbackTraces).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
traceId: "trace-1",
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-board callers before fetching a feedback trace", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
|
||||
@@ -439,6 +439,43 @@ describe("openclaw gateway adapter execute", () => {
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
paperclipWake: {
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-123",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
latestCommentId: "comment-2",
|
||||
comments: [
|
||||
{
|
||||
id: "comment-1",
|
||||
issueId: "issue-123",
|
||||
body: "First comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:00.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
{
|
||||
id: "comment-2",
|
||||
issueId: "issue-123",
|
||||
body: "Second comment",
|
||||
bodyTruncated: false,
|
||||
createdAt: "2026-03-28T14:35:10.000Z",
|
||||
author: { type: "user", id: "user-1" },
|
||||
},
|
||||
],
|
||||
commentWindow: {
|
||||
requestedCount: 2,
|
||||
includedCount: 2,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -456,6 +493,21 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
expect(String(payload?.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||
expect(String(payload?.message ?? "")).toContain(
|
||||
"Treat this wake payload as the highest-priority change for the current heartbeat.",
|
||||
);
|
||||
expect(String(payload?.message ?? "")).toContain(
|
||||
"Do not switch to another issue until you have handled this wake.",
|
||||
);
|
||||
expect(String(payload?.message ?? "")).toContain("First comment");
|
||||
expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]");
|
||||
expect(payload?.paperclip).toMatchObject({
|
||||
wake: {
|
||||
latestCommentId: "comment-2",
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { goalRoutes } from "../routes/goals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGoalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
|
||||
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackProjectCreated: mockTrackProjectCreated,
|
||||
trackGoalCreated: mockTrackGoalCreated,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
goalService: () => mockGoalService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => mockProjectService,
|
||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/workspace-runtime.js", () => ({
|
||||
startRuntimeServicesForWorkspaceControl: vi.fn(),
|
||||
stopRuntimeServicesForProjectWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
function createApp(route: ReturnType<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", route);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("project and goal telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Telemetry project",
|
||||
description: null,
|
||||
status: "backlog",
|
||||
});
|
||||
mockGoalService.create.mockResolvedValue({
|
||||
id: "goal-1",
|
||||
companyId: "company-1",
|
||||
title: "Telemetry goal",
|
||||
description: null,
|
||||
level: "team",
|
||||
status: "planned",
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("emits telemetry when a project is created", async () => {
|
||||
const res = await request(createApp(projectRoutes({} as any)))
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({ name: "Telemetry project" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
|
||||
it("emits telemetry when a goal is created", async () => {
|
||||
const res = await request(createApp(goalRoutes({} as any)))
|
||||
.post("/api/companies/company-1/goals")
|
||||
.send({ title: "Telemetry goal", level: "team" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
routineRuns,
|
||||
routines,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
|
||||
const mockTrackRoutineRun = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: () => mockTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackRoutineRun: mockTrackRoutineRun,
|
||||
};
|
||||
});
|
||||
|
||||
import { routineService } from "../services/routines.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("routine run telemetry", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routine-telemetry-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Routines",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const svc = routineService(db, {
|
||||
heartbeat: {
|
||||
wakeup: async (wakeupAgentId, wakeupOpts) => {
|
||||
const issueId =
|
||||
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId)
|
||||
|| (typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId)
|
||||
|| null;
|
||||
if (!issueId) return null;
|
||||
const queuedRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: queuedRunId,
|
||||
companyId,
|
||||
agentId: wakeupAgentId,
|
||||
invocationSource: wakeupOpts.source ?? "assignment",
|
||||
triggerDetail: wakeupOpts.triggerDetail ?? null,
|
||||
status: "queued",
|
||||
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: queuedRunId,
|
||||
executionLockedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, issueId));
|
||||
return { id: queuedRunId };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "Run telemetry test",
|
||||
description: "Routine body",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return { routine, svc };
|
||||
}
|
||||
|
||||
it("emits telemetry for routine runs from the service layer", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(mockTrackRoutineRun).toHaveBeenCalledWith(mockTelemetryClient, {
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,22 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackRoutineCreated: mockTrackRoutineCreated,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
@@ -104,6 +120,7 @@ function createApp(actor: Record<string, unknown>) {
|
||||
describe("routine routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockRoutineService.create.mockResolvedValue(routine);
|
||||
mockRoutineService.get.mockResolvedValue(routine);
|
||||
mockRoutineService.getTrigger.mockResolvedValue(trigger);
|
||||
@@ -267,5 +284,6 @@ describe("routine routes", () => {
|
||||
agentId: null,
|
||||
userId: "board-user",
|
||||
});
|
||||
expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,25 @@ describe("TelemetryClient periodic flush", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const lastCall = vi.mocked(fetch).mock.calls.at(-1);
|
||||
expect(lastCall?.[0]).toBe("http://localhost:9999/ingest");
|
||||
const requestInit = lastCall?.[1] as RequestInit | undefined;
|
||||
expect(requestInit?.method).toBe("POST");
|
||||
expect(requestInit?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(String(requestInit?.body ?? "{}"));
|
||||
expect(body).toMatchObject({
|
||||
app: "paperclip",
|
||||
schemaVersion: "1",
|
||||
installId: "test-install",
|
||||
version: "0.0.0-test",
|
||||
events: [
|
||||
{
|
||||
name: "install.started",
|
||||
dimensions: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(body.events[0]?.occurredAt).toEqual(expect.any(String));
|
||||
|
||||
// Second tick with no new events — no additional call
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { parse as parseEnvContents } from "dotenv";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
@@ -24,10 +26,12 @@ import {
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
resetRuntimeServicesForTests,
|
||||
resolveShell,
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
@@ -46,11 +50,16 @@ if (!embeddedPostgresSupport.supported) {
|
||||
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
const provisionWorktreeScriptPath = new URL("../../../scripts/provision-worktree.sh", import.meta.url);
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function runPnpm(cwd: string, args: string[]) {
|
||||
await execFileAsync("pnpm", args, { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo(defaultBranch = "main") {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
@@ -539,20 +548,123 @@ describe("realizeExecutionWorkspace", () => {
|
||||
path.join(expectedInstanceRoot, "secrets", "master.key"),
|
||||
);
|
||||
expect(envContents).not.toContain("DATABASE_URL=");
|
||||
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
|
||||
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
|
||||
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
|
||||
expect(envContents).toContain(
|
||||
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
|
||||
);
|
||||
const envVars = parseEnvContents(envContents);
|
||||
expect(envVars.PAPERCLIP_HOME).toBe(isolatedWorktreeHome);
|
||||
expect(envVars.PAPERCLIP_INSTANCE_ID).toBe(expectedInstanceId);
|
||||
expect(await fs.realpath(envVars.PAPERCLIP_CONFIG!)).toBe(await fs.realpath(configPath));
|
||||
expect(envVars.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(envVars.PAPERCLIP_WORKTREE_NAME).toBe("PAP-885-show-worktree-banner");
|
||||
|
||||
process.chdir(workspace.cwd);
|
||||
expect(resolvePaperclipConfigPath()).toBe(configPath);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it(
|
||||
"provisions worktree-local pnpm node_modules instead of reusing base-repo links",
|
||||
async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "workspace-root",
|
||||
private: true,
|
||||
packageManager: "pnpm@9.15.4",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "pnpm-workspace.yaml"),
|
||||
["packages:", " - packages/*", " - server", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "packages", "shared", "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@repo/shared",
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "server",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@repo/shared": "workspace:*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8");
|
||||
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
|
||||
await runPnpm(repoRoot, ["install"]);
|
||||
await runGit(repoRoot, ["add", "."]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-551",
|
||||
title: "Provision local workspace dependencies",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false);
|
||||
expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false);
|
||||
await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe(
|
||||
await fs.realpath(path.join(workspace.cwd, "packages", "shared")),
|
||||
);
|
||||
await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe(
|
||||
await fs.realpath(path.join(repoRoot, "packages", "shared")),
|
||||
);
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
@@ -1345,6 +1457,60 @@ describe("ensureRuntimeServicesForRun", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell (shell fallback)", () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell !== undefined) {
|
||||
process.env.SHELL = originalShell;
|
||||
} else {
|
||||
delete process.env.SHELL;
|
||||
}
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
});
|
||||
|
||||
it("returns process.env.SHELL when set", () => {
|
||||
process.env.SHELL = "/usr/bin/zsh";
|
||||
expect(resolveShell()).toBe("/usr/bin/zsh");
|
||||
});
|
||||
|
||||
it("trims whitespace from SHELL env var", () => {
|
||||
process.env.SHELL = " /usr/bin/fish ";
|
||||
expect(resolveShell()).toBe("/usr/bin/fish");
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh on non-Windows when SHELL is unset", () => {
|
||||
delete process.env.SHELL;
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(resolveShell()).toBe("/bin/sh");
|
||||
});
|
||||
|
||||
it("falls back to sh (bare) on Windows when SHELL is unset", () => {
|
||||
delete process.env.SHELL;
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(resolveShell()).toBe("sh");
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh on darwin when SHELL is unset", () => {
|
||||
delete process.env.SHELL;
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
expect(resolveShell()).toBe("/bin/sh");
|
||||
});
|
||||
|
||||
it("treats empty SHELL as unset and uses platform fallback", () => {
|
||||
process.env.SHELL = "";
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
expect(resolveShell()).toBe("/bin/sh");
|
||||
});
|
||||
|
||||
it("treats whitespace-only SHELL as unset and uses platform fallback", () => {
|
||||
process.env.SHELL = " ";
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(resolveShell()).toBe("sh");
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
@@ -1361,6 +1527,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
@@ -1475,6 +1642,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("marks persisted local services stopped when the registry pid is stale", async () => {
|
||||
const companyId = randomUUID();
|
||||
const runtimeServiceId = randomUUID();
|
||||
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||
const updatedAt = new Date("2026-04-04T17:10:00.000Z");
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime reconcile test",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values({
|
||||
id: runtimeServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
scopeType: "project_workspace",
|
||||
scopeId: projectWorkspaceId,
|
||||
serviceName: "paperclip-dev",
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
port: 49195,
|
||||
url: "http://127.0.0.1:49195",
|
||||
provider: "local_process",
|
||||
providerRef: "999999",
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: updatedAt,
|
||||
startedAt,
|
||||
stoppedAt: null,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "healthy",
|
||||
createdAt: startedAt,
|
||||
updatedAt,
|
||||
});
|
||||
await writeLocalServiceRegistryRecord({
|
||||
version: 1,
|
||||
serviceKey: "workspace-runtime-paperclip-dev-stale",
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName: "paperclip-dev",
|
||||
command: "pnpm dev",
|
||||
cwd: "/tmp/paperclip-primary",
|
||||
envFingerprint: "fingerprint",
|
||||
port: 49195,
|
||||
url: "http://127.0.0.1:49195",
|
||||
pid: 999999,
|
||||
processGroupId: 999999,
|
||||
provider: "local_process",
|
||||
runtimeServiceId,
|
||||
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||
startedAt: startedAt.toISOString(),
|
||||
lastSeenAt: updatedAt.toISOString(),
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
|
||||
expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 });
|
||||
const persisted = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted?.status).toBe("stopped");
|
||||
expect(persisted?.stoppedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("persists controlled execution workspace stops as stopped", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||
const companyId = randomUUID();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Adapter types shipped with Paperclip. External plugins must not replace these.
|
||||
*/
|
||||
export const BUILTIN_ADAPTER_TYPES = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"openclaw_gateway",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"hermes_local",
|
||||
"process",
|
||||
"http",
|
||||
]);
|
||||
@@ -1,4 +1,14 @@
|
||||
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
|
||||
export {
|
||||
getServerAdapter,
|
||||
listAdapterModels,
|
||||
listServerAdapters,
|
||||
findServerAdapter,
|
||||
findActiveServerAdapter,
|
||||
detectAdapterModel,
|
||||
registerServerAdapter,
|
||||
unregisterServerAdapter,
|
||||
requireServerAdapter,
|
||||
} from "./registry.js";
|
||||
export type {
|
||||
ServerAdapterModule,
|
||||
AdapterExecutionContext,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* External adapter plugin loader.
|
||||
*
|
||||
* Loads external adapter packages from the adapter-plugin-store and returns
|
||||
* their ServerAdapterModule instances. The caller (registry.ts) is
|
||||
* responsible for registering them.
|
||||
*
|
||||
* This avoids circular initialization: plugin-loader imports only
|
||||
* adapter-utils, never registry.ts.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ServerAdapterModule } from "./types.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
import {
|
||||
listAdapterPlugins,
|
||||
getAdapterPluginsDir,
|
||||
getAdapterPluginByType,
|
||||
} from "../services/adapter-plugin-store.js";
|
||||
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory UI parser cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const uiParserCache = new Map<string, string>();
|
||||
|
||||
export function getUiParserSource(adapterType: string): string | undefined {
|
||||
return uiParserCache.get(adapterType);
|
||||
}
|
||||
|
||||
/**
|
||||
* On cache miss, attempt on-demand extraction from the plugin store.
|
||||
* Makes the ui-parser.js endpoint self-healing.
|
||||
*/
|
||||
export function getOrExtractUiParserSource(adapterType: string): string | undefined {
|
||||
const cached = uiParserCache.get(adapterType);
|
||||
if (cached) return cached;
|
||||
|
||||
const record = getAdapterPluginByType(adapterType);
|
||||
if (!record) return undefined;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const source = extractUiParserSource(packageDir, record.packageName);
|
||||
if (source) {
|
||||
uiParserCache.set(adapterType, source);
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: record.packageName, origin: "lazy" },
|
||||
"UI parser extracted on-demand (cache miss)",
|
||||
);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolvePackageDir(record: Pick<AdapterPluginRecord, "localPath" | "packageName">): string {
|
||||
return record.localPath
|
||||
? path.resolve(record.localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
|
||||
}
|
||||
|
||||
function resolvePackageEntryPoint(packageDir: string): string {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (pkg.exports && typeof pkg.exports === "object" && pkg.exports["."]) {
|
||||
const exp = pkg.exports["."];
|
||||
return typeof exp === "string" ? exp : (exp.import ?? exp.default ?? "index.js");
|
||||
}
|
||||
return pkg.main ?? "index.js";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI parser extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPPORTED_PARSER_CONTRACT = "1";
|
||||
|
||||
function extractUiParserSource(
|
||||
packageDir: string,
|
||||
packageName: string,
|
||||
): string | undefined {
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
||||
|
||||
if (!pkg.exports || typeof pkg.exports !== "object" || !pkg.exports["./ui-parser"]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contractVersion = pkg.paperclip?.adapterUiParser;
|
||||
if (contractVersion) {
|
||||
const major = contractVersion.split(".")[0];
|
||||
if (major !== SUPPORTED_PARSER_CONTRACT) {
|
||||
logger.warn(
|
||||
{ packageName, contractVersion, supported: `${SUPPORTED_PARSER_CONTRACT}.x` },
|
||||
"Adapter declares unsupported UI parser contract version — skipping UI parser",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
{ packageName },
|
||||
"Adapter has ./ui-parser export but no paperclip.adapterUiParser version — loading anyway (future versions may require it)",
|
||||
);
|
||||
}
|
||||
|
||||
const uiParserExp = pkg.exports["./ui-parser"];
|
||||
const uiParserFile = typeof uiParserExp === "string"
|
||||
? uiParserExp
|
||||
: (uiParserExp.import ?? uiParserExp.default);
|
||||
const uiParserPath = path.resolve(packageDir, uiParserFile);
|
||||
|
||||
if (!uiParserPath.startsWith(packageDir + path.sep) && uiParserPath !== packageDir) {
|
||||
logger.warn(
|
||||
{ packageName, uiParserFile },
|
||||
"UI parser path escapes package directory — skipping",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(uiParserPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = fs.readFileSync(uiParserPath, "utf-8");
|
||||
logger.info(
|
||||
{ packageName, uiParserFile, size: source.length },
|
||||
`Loaded UI parser from adapter package${contractVersion ? "" : " (no version declared)"}`,
|
||||
);
|
||||
return source;
|
||||
} catch (err) {
|
||||
logger.warn({ err, packageName, uiParserFile }, "Failed to read UI parser from adapter package");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load / reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
|
||||
const m = mod as Record<string, unknown>;
|
||||
const createServerAdapter = m.createServerAdapter;
|
||||
if (typeof createServerAdapter !== "function") {
|
||||
throw new Error(
|
||||
`Package "${packageName}" does not export createServerAdapter(). ` +
|
||||
`Ensure the package's main entry exports a createServerAdapter function.`,
|
||||
);
|
||||
}
|
||||
|
||||
const adapterModule = createServerAdapter() as ServerAdapterModule;
|
||||
if (!adapterModule || !adapterModule.type) {
|
||||
throw new Error(
|
||||
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
|
||||
);
|
||||
}
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
export async function loadExternalAdapterPackage(
|
||||
packageName: string,
|
||||
localPath?: string,
|
||||
): Promise<ServerAdapterModule> {
|
||||
const packageDir = localPath
|
||||
? path.resolve(localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", packageName);
|
||||
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
const uiParserSource = extractUiParserSource(packageDir, packageName);
|
||||
|
||||
logger.info({ packageName, packageDir, entryPoint, modulePath, hasUiParser: !!uiParserSource }, "Loading external adapter package");
|
||||
|
||||
const mod = await import(modulePath);
|
||||
const adapterModule = validateAdapterModule(mod, packageName);
|
||||
|
||||
if (uiParserSource) {
|
||||
uiParserCache.set(adapterModule.type, uiParserSource);
|
||||
}
|
||||
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
|
||||
try {
|
||||
return await loadExternalAdapterPackage(record.packageName, record.localPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, packageName: record.packageName, type: record.type },
|
||||
"Failed to dynamically load external adapter; skipping",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload an external adapter at runtime (dev iteration without server restart).
|
||||
* Busts the ESM module cache via a cache-busting query string.
|
||||
*/
|
||||
export async function reloadExternalAdapter(
|
||||
type: string,
|
||||
): Promise<ServerAdapterModule | null> {
|
||||
const record = getAdapterPluginByType(type);
|
||||
if (!record) return null;
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
const fileUrl = `file://${modulePath}`;
|
||||
|
||||
// Bust ESM module cache so re-import loads fresh code from disk.
|
||||
// Query-string trick (?t=...) works in Node; Bun may need the file:// URL
|
||||
// to be evicted from its internal registry first.
|
||||
try {
|
||||
// @ts-expect-error -- Bun internal module cache
|
||||
const bunCache = globalThis.Bun?.__moduleCache as Map<string, unknown> | undefined;
|
||||
if (bunCache) {
|
||||
bunCache.delete(fileUrl);
|
||||
bunCache.delete(modulePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore — query-string fallback still works in Node
|
||||
}
|
||||
|
||||
const cacheBustUrl = `${fileUrl}?t=${Date.now()}`;
|
||||
|
||||
logger.info(
|
||||
{ type, packageName: record.packageName, modulePath, cacheBustUrl },
|
||||
"Reloading external adapter (cache bust)",
|
||||
);
|
||||
|
||||
const mod = await import(cacheBustUrl);
|
||||
const adapterModule = validateAdapterModule(mod, record.packageName);
|
||||
|
||||
uiParserCache.delete(type);
|
||||
const uiParserSource = extractUiParserSource(packageDir, record.packageName);
|
||||
if (uiParserSource) {
|
||||
uiParserCache.set(adapterModule.type, uiParserSource);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ type, packageName: record.packageName, hasUiParser: !!uiParserSource },
|
||||
"Successfully reloaded external adapter",
|
||||
);
|
||||
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all external adapter modules from the plugin store.
|
||||
*/
|
||||
export async function buildExternalAdapters(): Promise<ServerAdapterModule[]> {
|
||||
const results: ServerAdapterModule[] = [];
|
||||
|
||||
const storeRecords = listAdapterPlugins();
|
||||
for (const record of storeRecords) {
|
||||
const adapter = await loadFromRecord(record);
|
||||
if (adapter) {
|
||||
results.push(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
logger.info(
|
||||
{ count: results.length, adapters: results.map((a) => a.type) },
|
||||
"Loaded external adapters from plugin store",
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
+185
-12
@@ -79,6 +79,9 @@ import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
models as hermesModels,
|
||||
} from "hermes-paperclip-adapter";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
import { buildExternalAdapters } from "./plugin-loader.js";
|
||||
import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
@@ -188,8 +191,19 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
||||
detectModel: () => detectModelFromHermes(),
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>();
|
||||
|
||||
// For builtin types that are overridden by an external adapter, we keep the
|
||||
// original builtin so it can be restored when the override is deactivated.
|
||||
const builtinFallbacks = new Map<string, ServerAdapterModule>();
|
||||
|
||||
// Tracks which override types are currently deactivated (paused). When
|
||||
// paused, `getServerAdapter()` returns the builtin fallback instead of the
|
||||
// external. Persisted across reloads via the same disabled-adapters store.
|
||||
const pausedOverrides = new Set<string>();
|
||||
|
||||
function registerBuiltInAdapters() {
|
||||
for (const adapter of [
|
||||
claudeLocalAdapter,
|
||||
codexLocalAdapter,
|
||||
openCodeLocalAdapter,
|
||||
@@ -200,20 +214,109 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
hermesLocalAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
]) {
|
||||
adaptersByType.set(adapter.type, adapter);
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
const adapter = adaptersByType.get(type);
|
||||
registerBuiltInAdapters();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load external adapter plugins (e.g. droid_local)
|
||||
//
|
||||
// External adapter packages export createServerAdapter() which returns a
|
||||
// ServerAdapterModule. The host fills in sessionManagement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Cached sync wrapper — the store is a simple JSON file read, safe to call frequently. */
|
||||
function getDisabledAdapterTypesFromStore(): string[] {
|
||||
return getDisabledAdapterTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load external adapters from the plugin store and hardcoded sources.
|
||||
* Called once at module initialization. The promise is exported so that
|
||||
* callers (e.g. assertKnownAdapterType, app startup) can await completion
|
||||
* and avoid racing against the loading window.
|
||||
*/
|
||||
const externalAdaptersReady: Promise<void> = (async () => {
|
||||
try {
|
||||
const externalAdapters = await buildExternalAdapters();
|
||||
for (const externalAdapter of externalAdapters) {
|
||||
const overriding = BUILTIN_ADAPTER_TYPES.has(externalAdapter.type);
|
||||
if (overriding) {
|
||||
console.log(
|
||||
`[paperclip] External adapter "${externalAdapter.type}" overrides built-in adapter`,
|
||||
);
|
||||
// Save the original builtin for later restoration.
|
||||
const existing = adaptersByType.get(externalAdapter.type);
|
||||
if (existing && !builtinFallbacks.has(externalAdapter.type)) {
|
||||
builtinFallbacks.set(externalAdapter.type, existing);
|
||||
}
|
||||
}
|
||||
adaptersByType.set(
|
||||
externalAdapter.type,
|
||||
{
|
||||
...externalAdapter,
|
||||
sessionManagement: getAdapterSessionManagement(externalAdapter.type) ?? undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[paperclip] Failed to load external adapters:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Await this before validating adapter types to avoid race conditions
|
||||
* during server startup. External adapters are loaded asynchronously;
|
||||
* calling assertKnownAdapterType before this resolves will reject
|
||||
* valid external adapter types.
|
||||
*/
|
||||
export function waitForExternalAdapters(): Promise<void> {
|
||||
return externalAdaptersReady;
|
||||
}
|
||||
|
||||
export function registerServerAdapter(adapter: ServerAdapterModule): void {
|
||||
if (BUILTIN_ADAPTER_TYPES.has(adapter.type) && !builtinFallbacks.has(adapter.type)) {
|
||||
const existing = adaptersByType.get(adapter.type);
|
||||
if (existing) {
|
||||
builtinFallbacks.set(adapter.type, existing);
|
||||
}
|
||||
}
|
||||
adaptersByType.set(adapter.type, adapter);
|
||||
}
|
||||
|
||||
export function unregisterServerAdapter(type: string): void {
|
||||
if (type === processAdapter.type || type === httpAdapter.type) return;
|
||||
if (builtinFallbacks.has(type)) {
|
||||
pausedOverrides.delete(type);
|
||||
const fallback = builtinFallbacks.get(type);
|
||||
if (fallback) {
|
||||
adaptersByType.set(type, fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (BUILTIN_ADAPTER_TYPES.has(type)) {
|
||||
return;
|
||||
}
|
||||
adaptersByType.delete(type);
|
||||
}
|
||||
|
||||
export function requireServerAdapter(type: string): ServerAdapterModule {
|
||||
const adapter = findActiveServerAdapter(type);
|
||||
if (!adapter) {
|
||||
// Fall back to process adapter for unknown types
|
||||
return processAdapter;
|
||||
throw new Error(`Unknown adapter type: ${type}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
return findActiveServerAdapter(type) ?? processAdapter;
|
||||
}
|
||||
|
||||
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
const adapter = findActiveServerAdapter(type);
|
||||
if (!adapter) return [];
|
||||
if (adapter.listModels) {
|
||||
const discovered = await adapter.listModels();
|
||||
@@ -226,15 +329,85 @@ export function listServerAdapters(): ServerAdapterModule[] {
|
||||
return Array.from(adaptersByType.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* List adapters excluding those that are disabled in settings.
|
||||
* Used for menus and agent creation flows — disabled adapters remain
|
||||
* functional for existing agents but hidden from selection.
|
||||
*/
|
||||
export function listEnabledServerAdapters(): ServerAdapterModule[] {
|
||||
const disabled = getDisabledAdapterTypesFromStore();
|
||||
const disabledSet = disabled.length > 0 ? new Set(disabled) : null;
|
||||
return disabledSet
|
||||
? Array.from(adaptersByType.values()).filter((a) => !disabledSet.has(a.type))
|
||||
: Array.from(adaptersByType.values());
|
||||
}
|
||||
|
||||
export async function detectAdapterModel(
|
||||
type: string,
|
||||
): Promise<{ model: string; provider: string; source: string } | null> {
|
||||
const adapter = adaptersByType.get(type);
|
||||
): Promise<{ model: string; provider: string; source: string; candidates?: string[] } | null> {
|
||||
const adapter = findActiveServerAdapter(type);
|
||||
if (!adapter?.detectModel) return null;
|
||||
const detected = await adapter.detectModel();
|
||||
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
|
||||
if (!detected) return null;
|
||||
return {
|
||||
model: detected.model,
|
||||
provider: detected.provider,
|
||||
source: detected.source,
|
||||
...(detected.candidates?.length ? { candidates: detected.candidates } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override pause / resume
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pause or resume an external override for a builtin adapter type.
|
||||
*
|
||||
* - `paused = true` → subsequent calls to `getServerAdapter(type)` return
|
||||
* the builtin fallback instead of the external adapter. Already-running
|
||||
* agent sessions are unaffected (they hold a reference to the module they
|
||||
* started with).
|
||||
*
|
||||
* - `paused = false` → the external adapter is active again.
|
||||
*
|
||||
* Returns `true` if the state actually changed, `false` if the type is not
|
||||
* an override or was already in the requested state.
|
||||
*/
|
||||
export function setOverridePaused(type: string, paused: boolean): boolean {
|
||||
if (!builtinFallbacks.has(type)) return false;
|
||||
const wasPaused = pausedOverrides.has(type);
|
||||
if (paused && !wasPaused) {
|
||||
pausedOverrides.add(type);
|
||||
console.log(`[paperclip] Override paused for "${type}" — builtin adapter restored`);
|
||||
return true;
|
||||
}
|
||||
if (!paused && wasPaused) {
|
||||
pausedOverrides.delete(type);
|
||||
console.log(`[paperclip] Override resumed for "${type}" — external adapter active`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check whether the external override for a builtin type is currently paused. */
|
||||
export function isOverridePaused(type: string): boolean {
|
||||
return pausedOverrides.has(type);
|
||||
}
|
||||
|
||||
/** Get the set of types whose overrides are currently paused. */
|
||||
export function getPausedOverrides(): Set<string> {
|
||||
return pausedOverrides;
|
||||
}
|
||||
|
||||
export function findServerAdapter(type: string): ServerAdapterModule | null {
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
export function findActiveServerAdapter(type: string): ServerAdapterModule | null {
|
||||
if (pausedOverrides.has(type)) {
|
||||
const fallback = builtinFallbacks.get(type);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
return adaptersByType.get(type) ?? null;
|
||||
}
|
||||
|
||||
@@ -25,5 +25,8 @@ export type {
|
||||
NativeContextManagement,
|
||||
ResolvedSessionCompactionPolicy,
|
||||
SessionCompactionPolicy,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
ServerAdapterModule,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
+6
-1
@@ -29,6 +29,7 @@ import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
@@ -67,6 +68,7 @@ export async function createApp(
|
||||
feedbackExportService?: {
|
||||
flushPendingFeedbackTraces(input?: {
|
||||
companyId?: string;
|
||||
traceId?: string;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
}): Promise<unknown>;
|
||||
@@ -152,7 +154,9 @@ export async function createApp(
|
||||
api.use(agentRoutes(db));
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService));
|
||||
api.use(issueRoutes(db, opts.storageService, {
|
||||
feedbackExportService: opts.feedbackExportService,
|
||||
}));
|
||||
api.use(routineRoutes(db));
|
||||
api.use(executionWorkspaceRoutes(db));
|
||||
api.use(goalRoutes(db));
|
||||
@@ -226,6 +230,7 @@ export async function createApp(
|
||||
{ workerManager },
|
||||
),
|
||||
);
|
||||
api.use(adapterRoutes());
|
||||
api.use(
|
||||
accessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
|
||||
@@ -28,6 +28,9 @@ export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
|
||||
"../ui/node_modules/.vite-temp",
|
||||
"../ui/.vite",
|
||||
"../ui/dist",
|
||||
// npm install during reinstall would trigger a restart mid-request
|
||||
// if tsx watch sees the new files. Exclude the managed plugins dir.
|
||||
process.env.HOME + "/.paperclip/adapter-plugins",
|
||||
]) {
|
||||
addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath));
|
||||
}
|
||||
|
||||
+7
-1
@@ -525,7 +525,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||
const storageService = createStorageServiceFromConfig(config);
|
||||
const feedback = feedbackService(db as any, {
|
||||
shareClient: createFeedbackTraceShareClientFromConfig(config) ?? undefined,
|
||||
shareClient: createFeedbackTraceShareClientFromConfig(config),
|
||||
});
|
||||
const app = await createApp(db as any, {
|
||||
uiMode,
|
||||
@@ -668,6 +668,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
}, backupIntervalMs);
|
||||
}
|
||||
|
||||
// Wait for external adapters to finish loading before accepting requests.
|
||||
// Without this, adapter type validation (assertKnownAdapterType) would
|
||||
// reject valid external adapter types during the startup loading window.
|
||||
const { waitForExternalAdapters } = await import("./adapters/registry.js");
|
||||
await waitForExternalAdapters();
|
||||
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* @fileoverview Adapter management REST API routes
|
||||
*
|
||||
* This module provides Express routes for managing external adapter plugins:
|
||||
* - Listing all registered adapters (built-in + external)
|
||||
* - Installing external adapters from npm packages or local paths
|
||||
* - Unregistering external adapters
|
||||
*
|
||||
* All routes require board-level authentication (assertBoard middleware).
|
||||
*
|
||||
* @module server/routes/adapters
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Router } from "express";
|
||||
import {
|
||||
listServerAdapters,
|
||||
findServerAdapter,
|
||||
findActiveServerAdapter,
|
||||
listEnabledServerAdapters,
|
||||
registerServerAdapter,
|
||||
unregisterServerAdapter,
|
||||
isOverridePaused,
|
||||
setOverridePaused,
|
||||
} from "../adapters/registry.js";
|
||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
listAdapterPlugins,
|
||||
addAdapterPlugin,
|
||||
removeAdapterPlugin,
|
||||
getAdapterPluginByType,
|
||||
getAdapterPluginsDir,
|
||||
getDisabledAdapterTypes,
|
||||
setAdapterDisabled,
|
||||
} from "../services/adapter-plugin-store.js";
|
||||
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
|
||||
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
|
||||
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertBoard } from "./authz.js";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AdapterInstallRequest {
|
||||
/** npm package name (e.g., "droid-paperclip-adapter") or local path */
|
||||
packageName: string;
|
||||
/** True if packageName is a local filesystem path */
|
||||
isLocalPath?: boolean;
|
||||
/** Target version for npm packages (optional, defaults to latest) */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface AdapterInfo {
|
||||
type: string;
|
||||
label: string;
|
||||
source: "builtin" | "external";
|
||||
modelsCount: number;
|
||||
loaded: boolean;
|
||||
disabled: boolean;
|
||||
/** True when an external plugin has replaced a built-in adapter of the same type. */
|
||||
overriddenBuiltin?: boolean;
|
||||
/** True when the external override for a builtin type is currently paused. */
|
||||
overridePaused?: boolean;
|
||||
version?: string;
|
||||
packageName?: string;
|
||||
isLocalPath?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the adapter package directory (same rules as plugin-loader).
|
||||
*/
|
||||
function resolveAdapterPackageDir(record: AdapterPluginRecord): string {
|
||||
return record.localPath
|
||||
? path.resolve(record.localPath)
|
||||
: path.resolve(getAdapterPluginsDir(), "node_modules", record.packageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `version` from the adapter's package.json on disk.
|
||||
* This is the source of truth for what is actually installed (npm or local path).
|
||||
*/
|
||||
function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string | undefined {
|
||||
try {
|
||||
const pkgDir = resolveAdapterPackageDir(record);
|
||||
const raw = fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8");
|
||||
const v = JSON.parse(raw).version;
|
||||
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set<string>): AdapterInfo {
|
||||
const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined;
|
||||
return {
|
||||
type: adapter.type,
|
||||
label: adapter.type, // ServerAdapterModule doesn't have a separate "label" field; type serves as label
|
||||
source: externalRecord ? "external" : "builtin",
|
||||
modelsCount: (adapter.models ?? []).length,
|
||||
loaded: true, // If it's in the registry, it's loaded
|
||||
disabled: disabledSet.has(adapter.type),
|
||||
overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined,
|
||||
overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined,
|
||||
// Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields.
|
||||
version: fromDisk ?? externalRecord?.version,
|
||||
packageName: externalRecord?.packageName,
|
||||
isLocalPath: externalRecord?.localPath ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a local path that may be a Windows path into a WSL-compatible path.
|
||||
*
|
||||
* - Windows paths (e.g., "C:\\Users\\...") are converted via `wslpath -u`.
|
||||
* - Paths already starting with `/mnt/` or `/` are returned as-is.
|
||||
*/
|
||||
async function normalizeLocalPath(rawPath: string): Promise<string> {
|
||||
// Already a POSIX path (WSL or native Linux)
|
||||
if (rawPath.startsWith("/")) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
// Windows path detection: C:\ or C:/ pattern
|
||||
if (/^[A-Za-z]:[\\/]/.test(rawPath)) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("wslpath", ["-u", rawPath]);
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
logger.warn({ err, rawPath }, "wslpath conversion failed; using path as-is");
|
||||
return rawPath;
|
||||
}
|
||||
}
|
||||
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter module into the server registry, filling in
|
||||
* sessionManagement from the host.
|
||||
*/
|
||||
function registerWithSessionManagement(adapter: ServerAdapterModule): void {
|
||||
const wrapped: ServerAdapterModule = {
|
||||
...adapter,
|
||||
sessionManagement: getAdapterSessionManagement(adapter.type) ?? undefined,
|
||||
};
|
||||
registerServerAdapter(wrapped);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function adapterRoutes() {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/adapters
|
||||
*
|
||||
* List all registered adapters (built-in + external).
|
||||
* Each entry includes whether the adapter is built-in or external,
|
||||
* its model count, and load status.
|
||||
*/
|
||||
router.get("/adapters", async (_req, res) => {
|
||||
assertBoard(_req);
|
||||
|
||||
const registeredAdapters = listServerAdapters();
|
||||
const externalRecords = new Map(
|
||||
listAdapterPlugins().map((r) => [r.type, r]),
|
||||
);
|
||||
const disabledSet = new Set(getDisabledAdapterTypes());
|
||||
|
||||
const result: AdapterInfo[] = registeredAdapters.map((adapter) =>
|
||||
buildAdapterInfo(adapter, externalRecords.get(adapter.type), disabledSet),
|
||||
).sort((a, b) => a.type.localeCompare(b.type));
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/adapters/install
|
||||
*
|
||||
* Install an external adapter from an npm package or local path.
|
||||
*
|
||||
* Request body:
|
||||
* - packageName: string (required) — npm package name or local path
|
||||
* - isLocalPath?: boolean (default false)
|
||||
* - version?: string — target version for npm packages
|
||||
*/
|
||||
router.post("/adapters/install", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
|
||||
|
||||
if (!packageName || typeof packageName !== "string") {
|
||||
res.status(400).json({ error: "packageName is required and must be a string." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it
|
||||
// e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version
|
||||
let canonicalName = packageName;
|
||||
let explicitVersion = version;
|
||||
const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/);
|
||||
if (versionSuffix) {
|
||||
// For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3"
|
||||
// For unscoped: "name@1.2.3" → "name" + "1.2.3"
|
||||
const lastAtIndex = packageName.lastIndexOf("@");
|
||||
if (lastAtIndex > 0 && !explicitVersion) {
|
||||
canonicalName = packageName.slice(0, lastAtIndex);
|
||||
explicitVersion = versionSuffix[1];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let installedVersion: string | undefined;
|
||||
let moduleLocalPath: string | undefined;
|
||||
|
||||
if (!isLocalPath) {
|
||||
// npm install into the managed directory
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
|
||||
|
||||
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
|
||||
|
||||
await execFileAsync("npm", ["install", "--no-save", spec], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Read installed version from package.json
|
||||
try {
|
||||
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
|
||||
const pkgContent = await import("node:fs/promises");
|
||||
const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const v = pkg.version;
|
||||
installedVersion =
|
||||
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
|
||||
} catch {
|
||||
installedVersion = explicitVersion;
|
||||
}
|
||||
} else {
|
||||
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
|
||||
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
|
||||
try {
|
||||
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
|
||||
const v = JSON.parse(pkgRaw).version;
|
||||
if (typeof v === "string" && v.trim().length > 0) {
|
||||
installedVersion = v.trim();
|
||||
}
|
||||
} catch {
|
||||
// leave installedVersion undefined if package.json is missing
|
||||
}
|
||||
}
|
||||
|
||||
// Load and register the adapter (use canonicalName for path resolution)
|
||||
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
|
||||
|
||||
// Check if this type conflicts with a built-in adapter
|
||||
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
|
||||
res.status(409).json({
|
||||
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already registered (indicates a reinstall/update)
|
||||
const existing = findServerAdapter(adapterModule.type);
|
||||
const isReinstall = existing !== null;
|
||||
if (existing) {
|
||||
unregisterServerAdapter(adapterModule.type);
|
||||
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
|
||||
}
|
||||
|
||||
// Register the new adapter
|
||||
registerWithSessionManagement(adapterModule);
|
||||
|
||||
// Persist the record (use canonicalName without version suffix)
|
||||
const record: AdapterPluginRecord = {
|
||||
packageName: canonicalName,
|
||||
localPath: moduleLocalPath,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
type: adapterModule.type,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
addAdapterPlugin(record);
|
||||
|
||||
logger.info(
|
||||
{ type: adapterModule.type, packageName: canonicalName },
|
||||
"External adapter installed and registered",
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
type: adapterModule.type,
|
||||
packageName: canonicalName,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
installedAt: record.installedAt,
|
||||
requiresRestart: isReinstall,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, packageName }, "Failed to install external adapter");
|
||||
|
||||
// Distinguish npm errors from load errors
|
||||
if (message.includes("npm") || message.includes("ERR!")) {
|
||||
res.status(500).json({ error: `npm install failed: ${message}` });
|
||||
} else {
|
||||
res.status(500).json({ error: `Failed to install adapter: ${message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/adapters/:type
|
||||
*
|
||||
* Enable or disable an adapter. Disabled adapters are hidden from agent
|
||||
* creation menus but remain functional for existing agents.
|
||||
*
|
||||
* Request body: { "disabled": boolean }
|
||||
*/
|
||||
router.patch("/adapters/:type", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { disabled } = req.body as { disabled?: boolean };
|
||||
|
||||
if (typeof disabled !== "boolean") {
|
||||
res.status(400).json({ error: "Request body must include { \"disabled\": true|false }." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the adapter exists in the registry
|
||||
const existing = findServerAdapter(adapterType);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = setAdapterDisabled(adapterType, disabled);
|
||||
|
||||
if (changed) {
|
||||
logger.info({ type: adapterType, disabled }, "Adapter enabled/disabled");
|
||||
}
|
||||
|
||||
res.json({ type: adapterType, disabled, changed });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/adapters/:type/override
|
||||
*
|
||||
* Pause or resume an external adapter's override of a builtin type.
|
||||
* When paused, the server returns the builtin adapter for all new requests
|
||||
* (execute, listModels, config schema, etc.). Already-running sessions
|
||||
* keep the adapter they started with.
|
||||
*/
|
||||
router.patch("/adapters/:type/override", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
const { paused } = req.body as { paused?: boolean };
|
||||
|
||||
if (typeof paused !== "boolean") {
|
||||
res.status(400).json({ error: "\"paused\" (boolean) is required in request body." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!BUILTIN_ADAPTER_TYPES.has(adapterType)) {
|
||||
res.status(400).json({ error: `Type "${adapterType}" is not a builtin adapter.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = setOverridePaused(adapterType, paused);
|
||||
|
||||
logger.info({ type: adapterType, paused, changed }, "Adapter override toggle");
|
||||
|
||||
res.json({ type: adapterType, paused, changed });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/adapters/:type
|
||||
*
|
||||
* Unregister an external adapter. Built-in adapters cannot be removed.
|
||||
*/
|
||||
router.delete("/adapters/:type", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const adapterType = req.params.type;
|
||||
|
||||
if (!adapterType) {
|
||||
res.status(400).json({ error: "Adapter type is required." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent removal of built-in adapters
|
||||
if (BUILTIN_ADAPTER_TYPES.has(adapterType)) {
|
||||
res.status(403).json({
|
||||
error: `Cannot remove built-in adapter "${adapterType}".`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the adapter exists in the registry
|
||||
const existing = findServerAdapter(adapterType);
|
||||
if (!existing) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not registered.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that it's an external adapter
|
||||
const externalRecord = getAdapterPluginByType(adapterType);
|
||||
if (!externalRecord) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If installed via npm (has packageName but no localPath), run npm uninstall
|
||||
if (externalRecord.packageName && !externalRecord.localPath) {
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 60_000,
|
||||
});
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall completed for external adapter",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall failed for external adapter; continuing with unregister",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister from the runtime registry
|
||||
unregisterServerAdapter(adapterType);
|
||||
|
||||
// Remove from the persistent store
|
||||
removeAdapterPlugin(adapterType);
|
||||
|
||||
logger.info({ type: adapterType }, "External adapter unregistered and removed");
|
||||
|
||||
res.json({ type: adapterType, removed: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/adapters/:type/reload
|
||||
*
|
||||
* Reload an external adapter at runtime (for dev iteration without server restart).
|
||||
* Busts the ESM module cache, re-imports the adapter, and re-registers it.
|
||||
*
|
||||
* Cannot be used on built-in adapter types.
|
||||
*/
|
||||
router.post("/adapters/:type/reload", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
// Built-in adapters cannot be reloaded unless overridden by an external one
|
||||
if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) {
|
||||
res.status(400).json({ error: "Cannot reload built-in adapter." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the adapter module (busts ESM cache, re-imports)
|
||||
try {
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
|
||||
// Not found in the external adapter store
|
||||
if (!newModule) {
|
||||
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap in the reloaded module
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store.version from package.json (store may be missing version for local installs).
|
||||
const record = getAdapterPluginByType(type);
|
||||
let newVersion: string | undefined;
|
||||
if (record) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(record);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...record, version: newVersion });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
|
||||
|
||||
res.json({ type, version: newVersion, reloaded: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reload external adapter");
|
||||
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/adapters/:type/reinstall ──────────────────────────────────
|
||||
// Reinstall an npm-sourced external adapter (pulls latest from registry).
|
||||
// Local-path adapters cannot be reinstalled — use Reload instead.
|
||||
//
|
||||
// This is a convenience shortcut for remove + install with the same
|
||||
// package name, but without the risk of losing the store record.
|
||||
router.post("/adapters/:type/reinstall", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const type = req.params.type;
|
||||
|
||||
if (BUILTIN_ADAPTER_TYPES.has(type) && !getAdapterPluginByType(type)) {
|
||||
res.status(400).json({ error: "Cannot reinstall built-in adapter." });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = getAdapterPluginByType(type);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.localPath) {
|
||||
res.status(400).json({ error: "Local-path adapters cannot be reinstalled. Use Reload instead." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
|
||||
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
|
||||
|
||||
await execFileAsync("npm", ["install", "--no-save", record.packageName], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Reload the freshly installed adapter
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
if (!newModule) {
|
||||
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
|
||||
return;
|
||||
}
|
||||
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store version from disk
|
||||
let newVersion: string | undefined;
|
||||
const updatedRecord = getAdapterPluginByType(type);
|
||||
if (updatedRecord) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...updatedRecord, version: newVersion });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
|
||||
|
||||
res.json({ type, version: newVersion, reinstalled: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reinstall adapter");
|
||||
res.status(500).json({ error: `Reinstall failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/adapters/:type/config-schema ────────────────────────────────
|
||||
// Serve a declarative config schema for an adapter's UI form fields.
|
||||
// The adapter's getConfigSchema() resolves all options (static and dynamic)
|
||||
// so the UI receives a fully hydrated schema in a single fetch.
|
||||
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
|
||||
const CONFIG_SCHEMA_TTL_MS = 30_000;
|
||||
|
||||
router.get("/adapters/:type/config-schema", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const { type } = req.params;
|
||||
|
||||
const adapter = findActiveServerAdapter(type);
|
||||
if (!adapter) {
|
||||
res.status(404).json({ error: `Adapter "${type}" is not registered.` });
|
||||
return;
|
||||
}
|
||||
if (!adapter.getConfigSchema) {
|
||||
res.status(404).json({ error: `Adapter "${type}" does not provide a config schema.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = configSchemaCache.get(type);
|
||||
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
|
||||
res.json(cached.schema);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await adapter.getConfigSchema();
|
||||
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
|
||||
res.json(schema);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to resolve config schema");
|
||||
res.status(500).json({ error: `Failed to resolve config schema: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/adapters/:type/ui-parser.js ─────────────────────────────────
|
||||
// Serve the self-contained UI parser JS for an adapter type.
|
||||
// This allows external adapters to provide custom run-log parsing
|
||||
// without modifying Paperclip's source code.
|
||||
//
|
||||
// The adapter package must export a "./ui-parser" entry in package.json
|
||||
// pointing to a self-contained ESM module with zero runtime dependencies.
|
||||
router.get("/adapters/:type/ui-parser.js", (req, res) => {
|
||||
assertBoard(req);
|
||||
const { type } = req.params;
|
||||
const source = getOrExtractUiParserSource(type);
|
||||
if (!source) {
|
||||
res.status(404).json({ error: `No UI parser available for adapter "${type}".` });
|
||||
return;
|
||||
}
|
||||
res.type("application/javascript").send(source);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
+50
-18
@@ -27,6 +27,7 @@ import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
@@ -45,7 +46,13 @@ import {
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
||||
import {
|
||||
detectAdapterModel,
|
||||
findActiveServerAdapter,
|
||||
findServerAdapter,
|
||||
listAdapterModels,
|
||||
requireServerAdapter,
|
||||
} from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
@@ -62,12 +69,15 @@ import {
|
||||
loadDefaultAgentInstructionsBundle,
|
||||
resolveDefaultAgentInstructionsBundleRole,
|
||||
} from "../services/default-agent-instructions.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
claude_local: "instructionsFilePath",
|
||||
codex_local: "instructionsFilePath",
|
||||
droid_local: "instructionsFilePath",
|
||||
gemini_local: "instructionsFilePath",
|
||||
hermes_local: "instructionsFilePath",
|
||||
opencode_local: "instructionsFilePath",
|
||||
cursor: "instructionsFilePath",
|
||||
pi_local: "instructionsFilePath",
|
||||
@@ -320,6 +330,21 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownAdapterType(type: string | null | undefined): string {
|
||||
const adapterType = typeof type === "string" ? type.trim() : "";
|
||||
if (!adapterType) {
|
||||
throw unprocessable("Adapter type is required");
|
||||
}
|
||||
if (!findServerAdapter(adapterType)) {
|
||||
throw unprocessable(`Unknown adapter type: ${adapterType}`);
|
||||
}
|
||||
return adapterType;
|
||||
}
|
||||
|
||||
function hasOwn(value: object, key: string): boolean {
|
||||
return Object.hasOwn(value, key);
|
||||
}
|
||||
|
||||
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
const requestedCompanyId =
|
||||
@@ -741,7 +766,7 @@ export function agentRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
});
|
||||
@@ -749,7 +774,7 @@ export function agentRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
|
||||
const detected = await detectAdapterModel(type);
|
||||
res.json(detected);
|
||||
@@ -760,14 +785,10 @@ export function agentRoutes(db: Db) {
|
||||
validate(testAdapterEnvironmentSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const type = req.params.type as string;
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
await assertCanReadConfigurations(req, companyId);
|
||||
|
||||
const adapter = findServerAdapter(type);
|
||||
if (!adapter) {
|
||||
res.status(404).json({ error: `Unknown adapter type: ${type}` });
|
||||
return;
|
||||
}
|
||||
const adapter = requireServerAdapter(type);
|
||||
|
||||
const inputAdapterConfig =
|
||||
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
@@ -800,7 +821,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
|
||||
const adapter = findServerAdapter(agent.adapterType);
|
||||
const adapter = findActiveServerAdapter(agent.adapterType);
|
||||
if (!adapter?.listSkills) {
|
||||
const preference = readPaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
@@ -878,7 +899,7 @@ export function agentRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = findServerAdapter(updated.adapterType);
|
||||
const adapter = findActiveServerAdapter(updated.adapterType);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
updated.companyId,
|
||||
updated.adapterConfig,
|
||||
@@ -1263,6 +1284,7 @@ export function agentRoutes(db: Db) {
|
||||
sourceIssueIds: _sourceIssueIds,
|
||||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
@@ -1387,6 +1409,10 @@ export function agentRoutes(db: Db) {
|
||||
desiredSkills: desiredSkillAssignment.desiredSkills,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
@@ -1423,6 +1449,7 @@ export function agentRoutes(db: Db) {
|
||||
desiredSkills: requestedDesiredSkills,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
createInput.adapterType,
|
||||
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
@@ -1469,6 +1496,10 @@ export function agentRoutes(db: Db) {
|
||||
desiredSkills: desiredSkillAssignment.desiredSkills,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
companyId,
|
||||
@@ -1797,7 +1828,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) {
|
||||
if (hasOwn(req.body as object, "permissions")) {
|
||||
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
|
||||
return;
|
||||
}
|
||||
@@ -1805,7 +1836,7 @@ export function agentRoutes(db: Db) {
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
|
||||
if (hasOwn(patchData, "adapterConfig")) {
|
||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||
if (!adapterConfig) {
|
||||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
@@ -1820,16 +1851,17 @@ export function agentRoutes(db: Db) {
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType;
|
||||
const requestedAdapterType = hasOwn(patchData, "adapterType")
|
||||
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
|
||||
: existing.adapterType;
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
hasOwn(patchData, "adapterType") ||
|
||||
hasOwn(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration) {
|
||||
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
|
||||
const changingAdapterType =
|
||||
typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType;
|
||||
const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
const requestedAdapterConfig = hasOwn(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: null;
|
||||
if (
|
||||
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
companySkillImportSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
type SkillTelemetryInput = {
|
||||
key: string;
|
||||
slug: string;
|
||||
sourceType: string;
|
||||
sourceLocator: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function companySkillRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) {
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
|
||||
if (skill.sourceType === "skills_sh") {
|
||||
return skill.key;
|
||||
}
|
||||
if (skill.sourceType !== "github") {
|
||||
return null;
|
||||
}
|
||||
const hostname = asString(skill.metadata?.hostname);
|
||||
if (hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
return skill.key;
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -183,6 +213,15 @@ export function companySkillRoutes(db: Db) {
|
||||
warningCount: result.warnings.length,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
for (const skill of result.imported) {
|
||||
trackSkillImported(telemetryClient, {
|
||||
sourceType: skill.sourceType,
|
||||
skillRef: deriveTrackedSkillRef(skill),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared";
|
||||
import { trackGoalCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { goalService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function goalRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -42,6 +44,10 @@ export function goalRoutes(db: Db) {
|
||||
entityId: goal.id,
|
||||
details: { title: goal.title },
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackGoalCreated(telemetryClient, { goalLevel: goal.level });
|
||||
}
|
||||
res.status(201).json(goal);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
updateIssueWorkProductSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
type ExecutionWorkspace,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
@@ -52,7 +55,20 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
interrupt: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
export function issueRoutes(
|
||||
db: Db,
|
||||
storage: StorageService,
|
||||
opts?: {
|
||||
feedbackExportService?: {
|
||||
flushPendingFeedbackTraces(input?: {
|
||||
companyId?: string;
|
||||
traceId?: string;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const router = Router();
|
||||
const svc = issueService(db);
|
||||
const access = accessService(db);
|
||||
@@ -67,6 +83,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const routinesSvc = routineService(db);
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
@@ -220,6 +237,23 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||
if (!issue.executionWorkspaceId) return null;
|
||||
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||
if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null;
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function respondClosedIssueExecutionWorkspace(
|
||||
res: Response,
|
||||
workspace: Pick<ExecutionWorkspace, "closedAt" | "id" | "mode" | "name" | "status">,
|
||||
) {
|
||||
res.status(409).json({
|
||||
error: getClosedIsolatedExecutionWorkspaceMessage(workspace),
|
||||
executionWorkspace: workspace,
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await svc.getByIdentifier(rawId);
|
||||
@@ -1069,6 +1103,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
...updateFields
|
||||
} = req.body;
|
||||
let interruptedRunId: string | null = null;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||
|
||||
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interruptRequested) {
|
||||
if (!commentBody) {
|
||||
@@ -1375,6 +1416,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutRunId = requireAgentRunId(req, res);
|
||||
if (req.actor.type === "agent" && !checkoutRunId) return;
|
||||
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
|
||||
@@ -1593,6 +1640,11 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const reopenRequested = req.body.reopen === true;
|
||||
@@ -1867,6 +1919,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
);
|
||||
}
|
||||
|
||||
if (result.sharingEnabled && result.traceId && feedbackExportService) {
|
||||
try {
|
||||
await feedbackExportService.flushPendingFeedbackTraces({
|
||||
companyId: issue.companyId,
|
||||
traceId: result.traceId,
|
||||
limit: 1,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, issueId: issue.id, traceId: result.traceId }, "failed to flush shared feedback trace immediately");
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(result.vote);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -107,6 +109,10 @@ export function projectRoutes(db: Db) {
|
||||
workspaceId: createdWorkspaceId,
|
||||
},
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackProjectCreated(telemetryClient);
|
||||
}
|
||||
res.status(201).json(hydratedProject ?? project);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
updateRoutineSchema,
|
||||
updateRoutineTriggerSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, logActivity, routineService } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export function routineRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -76,6 +78,10 @@ export function routineRoutes(db: Db) {
|
||||
entityId: created.id,
|
||||
details: { title: created.title, assigneeAgentId: created.assigneeAgentId },
|
||||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackRoutineCreated(telemetryClient);
|
||||
}
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* JSON-file-backed store for external adapter registrations.
|
||||
*
|
||||
* Stores metadata about externally installed adapter packages at
|
||||
* ~/.paperclip/adapter-plugins.json. This is the source of truth for which
|
||||
* external adapters should be loaded at startup.
|
||||
*
|
||||
* Both the plugin store and the settings store are cached in memory after
|
||||
* the first read. Writes invalidate the cache so the next read picks up
|
||||
* the new state without a redundant disk round-trip.
|
||||
*
|
||||
* @module server/services/adapter-plugin-store
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdapterPluginRecord {
|
||||
/** npm package name (e.g., "droid-paperclip-adapter") */
|
||||
packageName: string;
|
||||
/** Absolute local filesystem path (for locally linked adapters) */
|
||||
localPath?: string;
|
||||
/** Installed version string (for npm packages) */
|
||||
version?: string;
|
||||
/** Adapter type identifier (matches ServerAdapterModule.type) */
|
||||
type: string;
|
||||
/** ISO 8601 timestamp of when the adapter was installed */
|
||||
installedAt: string;
|
||||
/** Whether this adapter is disabled (hidden from menus but still functional) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface AdapterSettings {
|
||||
disabledTypes: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip");
|
||||
const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins");
|
||||
const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json");
|
||||
const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory caches (invalidated on write)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let storeCache: AdapterPluginRecord[] | null = null;
|
||||
let settingsCache: AdapterSettings | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDirs(): void {
|
||||
fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true });
|
||||
const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json");
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify({
|
||||
name: "paperclip-adapter-plugins",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.",
|
||||
}, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function readStore(): AdapterPluginRecord[] {
|
||||
if (storeCache) return storeCache;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [];
|
||||
} catch {
|
||||
storeCache = [];
|
||||
}
|
||||
return storeCache;
|
||||
}
|
||||
|
||||
function writeStore(records: AdapterPluginRecord[]): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
|
||||
storeCache = records;
|
||||
}
|
||||
|
||||
function readSettings(): AdapterSettings {
|
||||
if (settingsCache) return settingsCache;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
settingsCache = parsed && Array.isArray(parsed.disabledTypes)
|
||||
? (parsed as AdapterSettings)
|
||||
: { disabledTypes: [] };
|
||||
} catch {
|
||||
settingsCache = { disabledTypes: [] };
|
||||
}
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
function writeSettings(settings: AdapterSettings): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
||||
settingsCache = settings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listAdapterPlugins(): AdapterPluginRecord[] {
|
||||
return readStore();
|
||||
}
|
||||
|
||||
export function addAdapterPlugin(record: AdapterPluginRecord): void {
|
||||
const store = [...readStore()];
|
||||
const idx = store.findIndex((r) => r.type === record.type);
|
||||
if (idx >= 0) {
|
||||
store[idx] = record;
|
||||
} else {
|
||||
store.push(record);
|
||||
}
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function removeAdapterPlugin(type: string): boolean {
|
||||
const store = [...readStore()];
|
||||
const idx = store.findIndex((r) => r.type === type);
|
||||
if (idx < 0) return false;
|
||||
store.splice(idx, 1);
|
||||
writeStore(store);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAdapterPluginByType(type: string): AdapterPluginRecord | undefined {
|
||||
return readStore().find((r) => r.type === type);
|
||||
}
|
||||
|
||||
export function getAdapterPluginsDir(): string {
|
||||
ensureDirs();
|
||||
return ADAPTER_PLUGINS_DIR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter enable/disable (settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getDisabledAdapterTypes(): string[] {
|
||||
return readSettings().disabledTypes;
|
||||
}
|
||||
|
||||
export function isAdapterDisabled(type: string): boolean {
|
||||
return readSettings().disabledTypes.includes(type);
|
||||
}
|
||||
|
||||
export function setAdapterDisabled(type: string, disabled: boolean): boolean {
|
||||
const settings = { ...readSettings(), disabledTypes: [...readSettings().disabledTypes] };
|
||||
const idx = settings.disabledTypes.indexOf(type);
|
||||
|
||||
if (disabled && idx < 0) {
|
||||
settings.disabledTypes.push(type);
|
||||
writeSettings(settings);
|
||||
return true;
|
||||
}
|
||||
if (!disabled && idx >= 0) {
|
||||
settings.disabledTypes.splice(idx, 1);
|
||||
writeSettings(settings);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
CompanySkillUsageAgent,
|
||||
} from "@paperclipai/shared";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { findServerAdapter } from "../adapters/index.js";
|
||||
import { findActiveServerAdapter } from "../adapters/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
@@ -1575,7 +1575,7 @@ export function companySkillService(db: Db) {
|
||||
|
||||
return Promise.all(
|
||||
desiredAgents.map(async (agent) => {
|
||||
const adapter = findServerAdapter(agent.adapterType);
|
||||
const adapter = findActiveServerAdapter(agent.adapterType);
|
||||
let actualState: string | null = null;
|
||||
|
||||
if (!adapter?.listSkills) {
|
||||
|
||||
@@ -14,6 +14,10 @@ import type {
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import {
|
||||
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||
} from "./workspace-runtime-read-model.js";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
@@ -317,6 +321,41 @@ function toExecutionWorkspace(
|
||||
};
|
||||
}
|
||||
|
||||
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||
}
|
||||
|
||||
async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
rows: ExecutionWorkspaceRow[],
|
||||
) {
|
||||
const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces(
|
||||
db,
|
||||
companyId,
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
const projectWorkspaceIds = rows
|
||||
.filter((row) => usesInheritedProjectRuntimeServices(row))
|
||||
.map((row) => row.projectWorkspaceId)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
companyId,
|
||||
[...new Set(projectWorkspaceIds)],
|
||||
);
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.id,
|
||||
usesInheritedProjectRuntimeServices(row)
|
||||
? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? [])
|
||||
: (executionRuntimeServices.get(row.id) ?? []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function executionWorkspaceService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: {
|
||||
@@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
|
||||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map((row) => toExecutionWorkspace(row));
|
||||
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows);
|
||||
return rows.map((row) =>
|
||||
toExecutionWorkspace(
|
||||
row,
|
||||
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
@@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
||||
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
|
||||
return toExecutionWorkspace(
|
||||
row,
|
||||
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||
);
|
||||
},
|
||||
|
||||
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
||||
@@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!workspace) return null;
|
||||
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
|
||||
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
|
||||
const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
|
||||
|
||||
const linkedIssues = await db
|
||||
.select({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type { FeedbackTraceBundle } from "@paperclipai/shared";
|
||||
import type { Config } from "../config.js";
|
||||
|
||||
const DEFAULT_FEEDBACK_EXPORT_BACKEND_URL = "https://telemetry.paperclip.ing";
|
||||
|
||||
function buildFeedbackShareObjectKey(bundle: FeedbackTraceBundle, exportedAt: Date) {
|
||||
const year = String(exportedAt.getUTCFullYear());
|
||||
const month = String(exportedAt.getUTCMonth() + 1).padStart(2, "0");
|
||||
@@ -14,10 +17,8 @@ export interface FeedbackTraceShareClient {
|
||||
|
||||
export function createFeedbackTraceShareClientFromConfig(
|
||||
config: Pick<Config, "feedbackExportBackendUrl" | "feedbackExportBackendToken">,
|
||||
): FeedbackTraceShareClient | null {
|
||||
const baseUrl = config.feedbackExportBackendUrl?.trim();
|
||||
if (!baseUrl) return null;
|
||||
|
||||
): FeedbackTraceShareClient {
|
||||
const baseUrl = config.feedbackExportBackendUrl?.trim() || DEFAULT_FEEDBACK_EXPORT_BACKEND_URL;
|
||||
const token = config.feedbackExportBackendToken?.trim();
|
||||
const endpoint = new URL("/feedback-traces", baseUrl).toString();
|
||||
|
||||
@@ -25,6 +26,11 @@ export function createFeedbackTraceShareClientFromConfig(
|
||||
async uploadTraceBundle(bundle) {
|
||||
const exportedAt = new Date();
|
||||
const objectKey = buildFeedbackShareObjectKey(bundle, exportedAt);
|
||||
const requestBody = JSON.stringify({
|
||||
objectKey,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
bundle,
|
||||
});
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -32,9 +38,8 @@ export function createFeedbackTraceShareClientFromConfig(
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
objectKey,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
bundle,
|
||||
encoding: "gzip+base64+json",
|
||||
payload: gzipSync(requestBody).toString("base64"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ const MAX_SKILLS = 20;
|
||||
const MAX_INSTRUCTION_FILES = 20;
|
||||
const MAX_TRACE_FILE_CHARS = 10_000_000;
|
||||
const DEFAULT_INSTANCE_SETTINGS_SINGLETON_KEY = "default";
|
||||
const FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED = "Feedback export backend is not configured";
|
||||
|
||||
type FeedbackTraceRow = typeof feedbackExports.$inferSelect & {
|
||||
issueIdentifier: string | null;
|
||||
@@ -1742,15 +1743,48 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
|
||||
|
||||
flushPendingFeedbackTraces: async (input?: {
|
||||
companyId?: string;
|
||||
traceId?: string;
|
||||
limit?: number;
|
||||
now?: Date;
|
||||
}) => {
|
||||
const shareClient = options.shareClient;
|
||||
if (!shareClient) {
|
||||
const filters = [eq(feedbackExports.status, "pending")];
|
||||
if (input?.companyId) {
|
||||
filters.push(eq(feedbackExports.companyId, input.companyId));
|
||||
}
|
||||
if (input?.traceId) {
|
||||
filters.push(eq(feedbackExports.id, input.traceId));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: feedbackExports.id,
|
||||
attemptCount: feedbackExports.attemptCount,
|
||||
})
|
||||
.from(feedbackExports)
|
||||
.where(and(...filters))
|
||||
.orderBy(asc(feedbackExports.createdAt), asc(feedbackExports.id))
|
||||
.limit(Math.max(1, Math.min(input?.limit ?? 25, 200)));
|
||||
|
||||
const attemptAt = input?.now ?? new Date();
|
||||
for (const row of rows) {
|
||||
await db
|
||||
.update(feedbackExports)
|
||||
.set({
|
||||
status: "failed",
|
||||
attemptCount: row.attemptCount + 1,
|
||||
lastAttemptedAt: attemptAt,
|
||||
failureReason: FEEDBACK_EXPORT_BACKEND_NOT_CONFIGURED,
|
||||
updatedAt: attemptAt,
|
||||
})
|
||||
.where(eq(feedbackExports.id, row.id));
|
||||
}
|
||||
|
||||
return {
|
||||
attempted: 0,
|
||||
attempted: rows.length,
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
failed: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1761,6 +1795,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
|
||||
if (input?.companyId) {
|
||||
filters.push(eq(feedbackExports.companyId, input.companyId));
|
||||
}
|
||||
if (input?.traceId) {
|
||||
filters.push(eq(feedbackExports.id, input.traceId));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -1983,7 +2020,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
|
||||
})
|
||||
.where(eq(feedbackVotes.id, savedVote.id));
|
||||
|
||||
await tx
|
||||
const [savedTrace] = await tx
|
||||
.insert(feedbackExports)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
@@ -2030,6 +2067,9 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
|
||||
failureReason: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning({
|
||||
id: feedbackExports.id,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2037,6 +2077,7 @@ export function feedbackService(db: Db, options: FeedbackServiceOptions = {}) {
|
||||
...savedVote,
|
||||
redactionSummary: artifacts.redactionSummary,
|
||||
},
|
||||
traceId: savedTrace?.id ?? null,
|
||||
consentEnabledNow,
|
||||
persistedSharingPreference,
|
||||
sharingEnabled: sharedWithLabs,
|
||||
|
||||
@@ -7,6 +7,12 @@ function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? record[key] ?? null : undefined;
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
@@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson(
|
||||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
export function buildHeartbeatRunIssueComment(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): string | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readCommentText(resultJson.summary)
|
||||
?? readCommentText(resultJson.result)
|
||||
?? readCommentText(resultJson.message)
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
@@ -31,7 +32,7 @@ import { companySkillService } from "./company-skills.js";
|
||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
@@ -66,10 +67,15 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
||||
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
|
||||
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const MAX_INLINE_WAKE_COMMENTS = 8;
|
||||
const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000;
|
||||
const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000;
|
||||
const execFile = promisify(execFileCallback);
|
||||
const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
@@ -685,7 +691,9 @@ function deriveCommentId(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1);
|
||||
return (
|
||||
batchedCommentId ??
|
||||
readNonEmptyString(contextSnapshot?.wakeCommentId) ??
|
||||
readNonEmptyString(contextSnapshot?.commentId) ??
|
||||
readNonEmptyString(payload?.commentId) ??
|
||||
@@ -693,6 +701,50 @@ function deriveCommentId(
|
||||
);
|
||||
}
|
||||
|
||||
export function extractWakeCommentIds(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
): string[] {
|
||||
const raw = contextSnapshot?.[WAKE_COMMENT_IDS_KEY];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of raw) {
|
||||
const value = readNonEmptyString(entry);
|
||||
if (!value || out.includes(value)) continue;
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeWakeCommentIds(...values: Array<unknown>): string[] {
|
||||
const merged: string[] = [];
|
||||
const append = (value: unknown) => {
|
||||
const normalized = readNonEmptyString(value);
|
||||
if (!normalized || merged.includes(normalized)) return;
|
||||
merged.push(normalized);
|
||||
};
|
||||
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) append(entry);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const batched = extractWakeCommentIds(candidate);
|
||||
if (batched.length > 0) {
|
||||
for (const entry of batched) append(entry);
|
||||
continue;
|
||||
}
|
||||
append(candidate.wakeCommentId);
|
||||
append(candidate.commentId);
|
||||
continue;
|
||||
}
|
||||
append(value);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function enrichWakeContextSnapshot(input: {
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
reason: string | null;
|
||||
@@ -705,6 +757,7 @@ function enrichWakeContextSnapshot(input: {
|
||||
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
|
||||
const taskKey = deriveTaskKey(contextSnapshot, payload);
|
||||
const wakeCommentId = deriveCommentId(contextSnapshot, payload);
|
||||
const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload);
|
||||
|
||||
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
|
||||
contextSnapshot.wakeReason = reason;
|
||||
@@ -721,7 +774,15 @@ function enrichWakeContextSnapshot(input: {
|
||||
if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) {
|
||||
contextSnapshot.commentId = commentIdFromPayload;
|
||||
}
|
||||
if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) {
|
||||
if (wakeCommentIds.length > 0) {
|
||||
const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1];
|
||||
contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds;
|
||||
contextSnapshot.commentId = latestCommentId;
|
||||
contextSnapshot.wakeCommentId = latestCommentId;
|
||||
// Once comment ids are normalized into the snapshot, rebuild the structured
|
||||
// wake payload from those ids later instead of carrying forward stale data.
|
||||
delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
} else if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) {
|
||||
contextSnapshot.wakeCommentId = wakeCommentId;
|
||||
}
|
||||
if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) {
|
||||
@@ -740,7 +801,7 @@ function enrichWakeContextSnapshot(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCoalescedContextSnapshot(
|
||||
export function mergeCoalescedContextSnapshot(
|
||||
existingRaw: unknown,
|
||||
incoming: Record<string, unknown>,
|
||||
) {
|
||||
@@ -749,14 +810,138 @@ function mergeCoalescedContextSnapshot(
|
||||
...existing,
|
||||
...incoming,
|
||||
};
|
||||
const commentId = deriveCommentId(incoming, null);
|
||||
if (commentId) {
|
||||
merged.commentId = commentId;
|
||||
merged.wakeCommentId = commentId;
|
||||
const mergedCommentIds = mergeWakeCommentIds(existing, incoming);
|
||||
if (mergedCommentIds.length > 0) {
|
||||
const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1];
|
||||
merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds;
|
||||
merged.commentId = latestCommentId;
|
||||
merged.wakeCommentId = latestCommentId;
|
||||
// The merged context should carry canonical comment ids; the next wake will
|
||||
// regenerate any structured payload from those ids.
|
||||
delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function buildPaperclipWakePayload(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
issueSummary?:
|
||||
| {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
}
|
||||
| null;
|
||||
}) {
|
||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||
if (commentIds.length === 0) return null;
|
||||
|
||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||
const issueSummary =
|
||||
input.issueSummary ??
|
||||
(issueId
|
||||
? await input.db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null);
|
||||
|
||||
const commentRows = await input.db
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
inArray(issueComments.id, commentIds),
|
||||
),
|
||||
);
|
||||
|
||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||
const comments: Array<Record<string, unknown>> = [];
|
||||
let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS;
|
||||
let truncated = false;
|
||||
let missingCommentCount = 0;
|
||||
|
||||
for (const commentId of commentIds) {
|
||||
const row = commentsById.get(commentId);
|
||||
if (!row) {
|
||||
truncated = true;
|
||||
missingCommentCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (comments.length >= MAX_INLINE_WAKE_COMMENTS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullBody = row.body;
|
||||
const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars);
|
||||
if (allowedBodyChars <= 0) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const body = fullBody.length > allowedBodyChars ? fullBody.slice(0, allowedBodyChars) : fullBody;
|
||||
const bodyTruncated = body.length < fullBody.length;
|
||||
if (bodyTruncated) truncated = true;
|
||||
remainingBodyChars -= body.length;
|
||||
|
||||
comments.push({
|
||||
id: row.id,
|
||||
issueId: row.issueId,
|
||||
body,
|
||||
bodyTruncated,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
author: row.authorAgentId
|
||||
? { type: "agent", id: row.authorAgentId }
|
||||
: row.authorUserId
|
||||
? { type: "user", id: row.authorUserId }
|
||||
: { type: "system", id: null },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
|
||||
issue: issueSummary
|
||||
? {
|
||||
id: issueSummary.id,
|
||||
identifier: issueSummary.identifier,
|
||||
title: issueSummary.title,
|
||||
status: issueSummary.status,
|
||||
priority: issueSummary.priority,
|
||||
}
|
||||
: null,
|
||||
commentIds,
|
||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||
comments,
|
||||
commentWindow: {
|
||||
requestedCount: commentIds.length,
|
||||
includedCount: comments.length,
|
||||
missingCount: missingCommentCount,
|
||||
},
|
||||
truncated,
|
||||
fallbackFetchNeeded: truncated || missingCommentCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function runTaskKey(run: typeof heartbeatRuns.$inferSelect) {
|
||||
return deriveTaskKey(run.contextSnapshot as Record<string, unknown> | null, null);
|
||||
}
|
||||
@@ -2098,6 +2283,8 @@ export function heartbeatService(db: Db) {
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
@@ -2168,12 +2355,33 @@ export function heartbeatService(db: Db) {
|
||||
id: issueContext.id,
|
||||
identifier: issueContext.identifier,
|
||||
title: issueContext.title,
|
||||
status: issueContext.status,
|
||||
priority: issueContext.priority,
|
||||
projectId: issueContext.projectId,
|
||||
projectWorkspaceId: issueContext.projectWorkspaceId,
|
||||
executionWorkspaceId: issueContext.executionWorkspaceId,
|
||||
executionWorkspacePreference: issueContext.executionWorkspacePreference,
|
||||
}
|
||||
: null;
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
contextSnapshot: context,
|
||||
issueSummary: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
if (paperclipWakePayload) {
|
||||
context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
|
||||
} else {
|
||||
delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const shouldReuseExisting =
|
||||
@@ -2838,6 +3046,19 @@ export function heartbeatService(db: Db) {
|
||||
exitCode: adapterResult.exitCode,
|
||||
},
|
||||
});
|
||||
if (issueId && outcome === "succeeded") {
|
||||
try {
|
||||
const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id });
|
||||
}
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to post run summary comment: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
import { findActiveServerAdapter } from "../adapters/registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function notifyHireApproved(
|
||||
}
|
||||
|
||||
const adapterType = row.adapterType ?? "process";
|
||||
const adapter = findServerAdapter(adapterType);
|
||||
const adapter = findActiveServerAdapter(adapterType);
|
||||
const onHireApproved = adapter?.onHireApproved;
|
||||
if (!onHireApproved) {
|
||||
return;
|
||||
|
||||
@@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
||||
const records = await listLocalServiceRegistryRecords(
|
||||
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
||||
);
|
||||
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
|
||||
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
|
||||
if (!record) return null;
|
||||
|
||||
let candidate = record;
|
||||
if (!isPidAlive(candidate.pid)) {
|
||||
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
|
||||
if (!ownerPid) {
|
||||
await removeLocalServiceRegistryRecord(candidate.serviceKey);
|
||||
return null;
|
||||
}
|
||||
candidate = {
|
||||
...candidate,
|
||||
pid: ownerPid,
|
||||
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
await writeLocalServiceRegistryRecord(candidate);
|
||||
}
|
||||
|
||||
if (!(await isLikelyMatchingCommand(candidate))) {
|
||||
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function isPidAlive(pid: number) {
|
||||
@@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||
const commandLine = stdout.trim();
|
||||
if (!commandLine) return false;
|
||||
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
|
||||
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
||||
const normalizedCommandLine = normalize(commandLine);
|
||||
const normalizedRecordedCommand = normalize(record.command);
|
||||
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -807,7 +807,7 @@ export function buildHostServices(
|
||||
return (await issues.addComment(
|
||||
params.issueId,
|
||||
params.body,
|
||||
{},
|
||||
{ agentId: params.authorAgentId },
|
||||
)) as IssueComment;
|
||||
},
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user