diff --git a/AGENTS.md b/AGENTS.md index bdfa3e5d..dc5e9432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,18 @@ When adding endpoints: - Use company selection context for company-scoped pages - Surface failures clearly; do not silently ignore API errors -## 10. Definition of Done +## 10. Pull Request Requirements + +When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections: + +- **Thinking Path** — trace reasoning from project context to this change (see `CONTRIBUTING.md` for examples) +- **What Changed** — bullet list of concrete changes +- **Verification** — how a reviewer can confirm it works +- **Risks** — what could go wrong +- **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used. +- **Checklist** — all items checked + +## 11. Definition of Done A change is done when all are true: @@ -146,3 +157,45 @@ 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 +5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used) + +## 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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f18ba10..39b8c5c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,11 @@ PRs that follow this path are **much** more likely to be accepted, even when the ### 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. +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, Model Used, and a Checklist. + +### Model Used (Required) + +Every PR must include a **Model Used** section specifying which AI model produced or assisted with the change. Include the provider, exact model ID/version, context window size, and any relevant capability details (e.g., reasoning mode, tool use). If no AI was used, write "None — human-authored". This applies to all contributors — human and AI alike. ### Tests Must Pass diff --git a/adapter-plugin.md b/adapter-plugin.md new file mode 100644 index 00000000..13994ba3 --- /dev/null +++ b/adapter-plugin.md @@ -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 diff --git a/docs/adapters/adapter-ui-parser.md b/docs/adapters/adapter-ui-parser.md new file mode 100644 index 00000000..a9390001 --- /dev/null +++ b/docs/adapters/adapter-ui-parser.md @@ -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 diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index d3a0b68b..fc64fcf8 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,8 +20,8 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) | -| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | +| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (default: `true`); required for headless runs where interactive approval is impossible | ## Prompt Templates diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index fae0e4b3..ae5e4ccb 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -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. +## 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// +packages/adapters// # 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 | null }; + config: Record; // agent's adapterConfig + context: Record; // task, wake reason, etc. + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onMeta?: (meta: AdapterInvocationMeta) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; +} +``` + +### AdapterExecutionResult + +```ts +interface AdapterExecutionResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + errorMessage?: string | null; + usage?: { inputTokens: number; outputTokens: number }; + sessionParams?: Record | 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 { + 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//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 diff --git a/docs/adapters/external-adapters.md b/docs/adapters/external-adapters.md new file mode 100644 index 00000000..3c814fc9 --- /dev/null +++ b/docs/adapters/external-adapters.md @@ -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 { + 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 { + 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 " \ + -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 " \ + -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; + 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 diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 3216b5e5..dfb4b21f 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -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// +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. diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index f3672723..cafc39e9 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -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). diff --git a/docs/docs.json b/docs/docs.json index f87809af..be48cc8e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,8 @@ "adapters/codex-local", "adapters/process", "adapters/http", + "adapters/external-adapters", + "adapters/adapter-ui-parser", "adapters/creating-an-adapter" ] } diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index 2f754bce..41b17201 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -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/-/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: diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 943db253..6770ae51 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -22,6 +22,9 @@ export type { AdapterModel, HireApprovedPayload, HireApprovedHookResult, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts index 6c5554e1..96cfba6d 100644 --- a/packages/adapter-utils/src/log-redaction.ts +++ b/packages/adapter-utils/src/log-redaction.ts @@ -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 { diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4a5affdf..f486f675 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 308b54a3..90fe544b 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -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 { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 9337fad0..429143d5 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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; +} + +export interface AdapterConfigSchema { + fields: ConfigFieldSchema[]; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -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; } // --------------------------------------------------------------------------- @@ -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; } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 41c0693f..b2f85732 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -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 diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c7d6c6a8..eca82b50 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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 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 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 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, }; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 98d570c4..04865dd0 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 63d2dc95..c6ba3e9b 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -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 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 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 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 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 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 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 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 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 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, claimedApiKeyPath: string): string { +function buildWakeText( + payload: WakePayload, + paperclipEnv: Record, + structuredWakePrompt: string, +): string { + const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; const orderedKeys = [ "PAPERCLIP_RUN_ID", "PAPERCLIP_AGENT_ID", @@ -409,6 +421,12 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record 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, @@ -452,6 +481,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; @@ -1058,8 +1091,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 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 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 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 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; listComments(issueId: string, companyId: string): Promise; - createComment(issueId: string, body: string, companyId: string): Promise; + createComment( + issueId: string, + body: string, + companyId: string, + options?: { authorAgentId?: string }, + ): Promise; /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ documents: PluginIssueDocumentsClient; } diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index a64d225a..483dbc70 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -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: { diff --git a/packages/shared/src/adapter-type.ts b/packages/shared/src/adapter-type.ts new file mode 100644 index 00000000..5af29dfc --- /dev/null +++ b/packages/shared/src/adapter-type.ts @@ -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(); diff --git a/packages/shared/src/adapter-types.test.ts b/packages/shared/src/adapter-types.test.ts new file mode 100644 index 00000000..29fb6eec --- /dev/null +++ b/packages/shared/src/adapter-types.test.ts @@ -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"); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 1e82a5ce..59d58441 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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", diff --git a/packages/shared/src/execution-workspace-guards.ts b/packages/shared/src/execution-workspace-guards.ts new file mode 100644 index 00000000..5428546a --- /dev/null +++ b/packages/shared/src/execution-workspace-guards.ts @@ -0,0 +1,19 @@ +import type { ExecutionWorkspace } from "./types/workspace-runtime.js"; + +type ExecutionWorkspaceGuardTarget = Pick; + +const CLOSED_EXECUTION_WORKSPACE_STATUSES = new Set(["archived", "cleanup_failed"]); + +export function isClosedIsolatedExecutionWorkspace( + workspace: Pick | 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, +): string { + return `This issue is linked to the closed workspace "${workspace.name}". Move it to an open workspace before adding comments or resuming work.`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0f936bc2..cc6ea42c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 55f27369..5a156959 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -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"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 66be8948..117fad39 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -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(); @@ -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(); + 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; +} diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 939a32ed..a8d6aefb 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -58,6 +58,7 @@ export class TelemetryClient { app, schemaVersion, installId: state.installId, + version: this.version, events, }), signal: controller.signal, diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 1ed96bb6..6b30995e 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -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 }, diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts index 1757276e..f80de29c 100644 --- a/packages/shared/src/telemetry/index.ts +++ b/packages/shared/src/telemetry/index.ts @@ -5,6 +5,12 @@ export { trackInstallStarted, trackInstallCompleted, trackCompanyImported, + trackProjectCreated, + trackRoutineCreated, + trackRoutineRun, + trackGoalCreated, + trackAgentCreated, + trackSkillImported, trackAgentFirstHeartbeat, trackAgentTaskCompleted, trackErrorHandlerCrash, diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index a8e3d4dc..d3552d0d 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -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" diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 126a0843..6da95c12 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -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. diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 288ae683..7b462db7 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -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), diff --git a/releases/v2026.403.0.md b/releases/v2026.403.0.md index 8fca3a25..585772c0 100644 --- a/releases/v2026.403.0.md +++ b/releases/v2026.403.0.md @@ -4,19 +4,19 @@ ## Highlights -- **Execution workspaces** — 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)) - **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)) -- **Telemetry** — App-side telemetry sender with periodic flush, graceful shutdown, plugin telemetry bridge, and server-side telemetry aligned with the backend schema. Agent role is used in task-completed events. ([#2527](https://github.com/paperclipai/paperclip/pull/2527)) - **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. -- **Comment interrupts** — New interrupt support for issue comments with queued comment thread UX. The interrupt checkbox was later removed in favor of a cleaner flow. - **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) -- **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) - **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. diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 09cfc36b..861a6037 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -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 ) diff --git a/scripts/screenshot.cjs b/scripts/screenshot.cjs new file mode 100644 index 00000000..f267bd06 --- /dev/null +++ b/scripts/screenshot.cjs @@ -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 [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 [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(); + } +})(); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts new file mode 100644 index 00000000..6f7b0973 --- /dev/null +++ b/server/src/__tests__/adapter-registry.test.ts @@ -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); + }); +}); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts new file mode 100644 index 00000000..c1ce6c3a --- /dev/null +++ b/server/src/__tests__/adapter-routes.test.ts @@ -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"); + }); +}); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts new file mode 100644 index 00000000..55b9b85b --- /dev/null +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -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) => config), + resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ 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) => ({ + 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 | undefined) ?? {}, + runtimeConfig: (input.runtimeConfig as Record | 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"); + }); +}); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 16b16ca3..1f65c26d 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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(), })); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 8590d988..5523323f 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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 () => { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 4f584435..da648367 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -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 = {}; + 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"); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 8ac0785d..3814dc08 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -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( + "@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) { 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", diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index d4a50bdc..ca6b38d5 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -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); + }); }); diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 898ae0aa..8ce7f2fc 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -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(); + }); }); diff --git a/server/src/__tests__/feedback-share-client.test.ts b/server/src/__tests__/feedback-share-client.test.ts new file mode 100644 index 00000000..fe710262 --- /dev/null +++ b/server/src/__tests__/feedback-share-client.test.ts @@ -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" }); + }); +}); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 06fdaf03..93a4aadd 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -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 }); + } + }); }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts new file mode 100644 index 00000000..ac205b7b --- /dev/null +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -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; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + 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, 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> = []; + let firstWaitRelease: (() => void) | null = null; + let firstWaitGate = new Promise((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; + }; + + 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); + 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((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((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +describe("heartbeat comment wake batching", () => { + let db!: ReturnType; + 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 | null)?._paperclipWakeContext as + | Record + | 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); +}); diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index ec6bc2d9..79efdabe 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -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(); + }); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 17718055..859c8960 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -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({ diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 0a2cbbfd..b08e04bc 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -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 }): 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); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts new file mode 100644 index 00000000..f7f0240c --- /dev/null +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -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 }); + }); +}); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 0bb7dc34..fe2ba1fa 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -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) { (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", diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 07bab9da..9bb85b7c 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -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 { diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts new file mode 100644 index 00000000..ac41af63 --- /dev/null +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -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( + "@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 | ReturnType) { + 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" }); + }); +}); diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts new file mode 100644 index 00000000..ded45597 --- /dev/null +++ b/server/src/__tests__/routine-run-telemetry.test.ts @@ -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( + "@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; + let tempDb: Awaited> | 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", + }); + }); +}); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 0c3c0b2b..aeb943c0 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -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( + "@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) { 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()); }); }); diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts index b057ef9d..2264638c 100644 --- a/server/src/__tests__/telemetry-client-flush.test.ts +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -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); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 8bf008bd..911010f5 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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"; @@ -29,6 +31,7 @@ import { 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"; @@ -47,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"]); @@ -540,13 +548,12 @@ 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); @@ -555,6 +562,110 @@ describe("realizeExecutionWorkspace", () => { } }, 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(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); @@ -1416,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); @@ -1530,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(); diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts new file mode 100644 index 00000000..463a5694 --- /dev/null +++ b/server/src/adapters/builtin-adapter-types.ts @@ -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", +]); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8be40a51..49530dc7 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -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, diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts new file mode 100644 index 00000000..a9f70463 --- /dev/null +++ b/server/src/adapters/plugin-loader.ts @@ -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(); + +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): 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; + 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 { + 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 { + 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 { + 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 | 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 { + 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; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 1f195f86..8a30a9c8 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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( - [ +const adaptersByType = new Map(); + +// 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(); + +// 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(); + +function registerBuiltInAdapters() { + for (const adapter of [ claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, @@ -200,20 +214,109 @@ const adaptersByType = new Map( 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 = (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 { + 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 { + 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; +} diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 7df54741..b8f32568 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -25,5 +25,8 @@ export type { NativeContextManagement, ResolvedSessionCompactionPolicy, SessionCompactionPolicy, + ConfigFieldOption, + ConfigFieldSchema, + AdapterConfigSchema, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/app.ts b/server/src/app.ts index b9faee2f..686ecfde 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; @@ -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, diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts index cd618f73..4fe3769d 100644 --- a/server/src/dev-watch-ignore.ts +++ b/server/src/dev-watch-ignore.ts @@ -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)); } diff --git a/server/src/index.ts b/server/src/index.ts index 37318245..b417f14c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -525,7 +525,7 @@ export async function startServer(): Promise { 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 { }, 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((resolveListen, rejectListen) => { const onError = (err: Error) => { server.off("error", onError); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts new file mode 100644 index 00000000..27e32a06 --- /dev/null +++ b/server/src/routes/adapters.ts @@ -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): 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 { + // 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(); + 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; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8ec5ffb1..f1c15b8d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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 = { 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 { 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; @@ -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, @@ -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), @@ -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), @@ -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) }; 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 ( diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 7b239832..9e91bf26 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -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 | null; +}; export function companySkillRoutes(db: Db) { const router = Router(); @@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) { return Boolean((agent.permissions as Record).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); }, diff --git a/server/src/routes/goals.ts b/server/src/routes/goals.ts index 450f9467..2f090dad 100644 --- a/server/src/routes/goals.ts +++ b/server/src/routes/goals.ts @@ -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); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5eb0b83e..2898feae 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; + }; + }, +) { 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, + ) { + res.status(409).json({ + error: getClosedIsolatedExecutionWorkspaceMessage(workspace), + executionWorkspace: workspace, + }); + } + async function normalizeIssueIdentifier(rawId: string): Promise { 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); }); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index b200b354..482a6983 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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); }); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index e7887e88..7045a52d 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -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); }); diff --git a/server/src/services/adapter-plugin-store.ts b/server/src/services/adapter-plugin-store.ts new file mode 100644 index 00000000..8c26abe8 --- /dev/null +++ b/server/src/services/adapter-plugin-store.ts @@ -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; +} diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index fae77e5f..faea351c 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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) { diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index a1d3b41d..58a43210 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -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 | 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 => { @@ -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({ diff --git a/server/src/services/feedback-share-client.ts b/server/src/services/feedback-share-client.ts index e1761b14..7fdf97ca 100644 --- a/server/src/services/feedback-share-client.ts +++ b/server/src/services/feedback-share-client.ts @@ -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, -): 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"), }), }); diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index 312cd5bf..26ce6107 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -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, diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 4ef07047..441b0882 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -7,6 +7,12 @@ function readNumericField(record: Record, 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 | null | undefined, ): Record | null { @@ -33,3 +39,18 @@ export function summarizeHeartbeatRunResultJson( return Object.keys(summary).length > 0 ? summary : null; } + +export function buildHeartbeatRunIssueComment( + resultJson: Record | 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 + ); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 356783de..0a3a1479 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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>(); 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 | null | undefined, payload: Record | 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 | 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): 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; + 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; 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, ) { @@ -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; + 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> = []; + 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 | 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); } diff --git a/server/src/services/hire-hook.ts b/server/src/services/hire-hook.ts index 6b6e22ce..79a38177 100644 --- a/server/src/services/hire-hook.ts +++ b/server/src/services/hire-hook.ts @@ -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; diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 68dbbdc8..eac87732 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -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; } diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4d487552..22ccb017 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -807,7 +807,7 @@ export function buildHostServices( return (await issues.addComment( params.issueId, params.body, - {}, + { agentId: params.authorAgentId }, )) as IssueComment; }, }, diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index db786478..f653ea1a 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -14,7 +14,7 @@ import { type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; -import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; +import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; @@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise workspace.id), @@ -541,7 +541,7 @@ export function projectService(db: Db) { .where(eq(projectWorkspaces.projectId, projectId)) .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); if (rows.length === 0) return []; - const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( + const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces( db, rows[0]!.companyId, rows.map((workspace) => workspace.id), diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 323462e0..f1f9e1ef 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -31,8 +31,10 @@ import { stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, } from "@paperclipai/shared"; +import { trackRoutineRun } from "@paperclipai/shared/telemetry"; import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; +import { getTelemetryClient } from "../telemetry.js"; import { issueService } from "./issues.js"; import { secretService } from "./secrets.js"; import { parseCron, validateCron } from "./cron.js"; @@ -856,6 +858,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup } } + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackRoutineRun(telemetryClient, { + source: run.source, + status: run.status, + }); + } + return run; } diff --git a/server/src/services/workspace-runtime-read-model.ts b/server/src/services/workspace-runtime-read-model.ts new file mode 100644 index 00000000..dba6190d --- /dev/null +++ b/server/src/services/workspace-runtime-read-model.ts @@ -0,0 +1,96 @@ +import type { Db } from "@paperclipai/db"; +import { workspaceRuntimeServices } from "@paperclipai/db"; +import { and, desc, eq, inArray } from "drizzle-orm"; + +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; + +function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) { + if (row.reuseKey) return row.reuseKey; + return [ + row.scopeType, + row.scopeId ?? "", + row.projectWorkspaceId ?? "", + row.executionWorkspaceId ?? "", + row.serviceName, + row.command ?? "", + row.cwd ?? "", + ].join(":"); +} + +export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) { + const current = new Map(); + for (const row of rows) { + const identity = runtimeServiceIdentityKey(row); + if (!current.has(identity)) current.set(identity, row); + } + return [...current.values()]; +} + +export async function listCurrentRuntimeServicesForProjectWorkspaces( + db: Db, + companyId: string, + projectWorkspaceIds: string[], +) { + if (projectWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.projectWorkspaceId) continue; + const existing = grouped.get(row.projectWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.projectWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} + +export async function listCurrentRuntimeServicesForExecutionWorkspaces( + db: Db, + companyId: string, + executionWorkspaceIds: string[], +) { + if (executionWorkspaceIds.length === 0) return new Map(); + + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, companyId), + inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + + const grouped = new Map(); + for (const row of rows) { + if (!row.executionWorkspaceId) continue; + const existing = grouped.get(row.executionWorkspaceId) ?? []; + existing.push(row); + grouped.set(row.executionWorkspaceId, existing); + } + + return new Map( + Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [ + workspaceId, + selectCurrentRuntimeServiceRows(workspaceRows), + ]), + ); +} diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index a100242e..44040311 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1081,6 +1081,16 @@ async function waitForReadiness(input: { throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); } +async function isRuntimeServiceUrlHealthy(url: string | null) { + if (!url) return true; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + return response.ok; + } catch { + return false; + } +} + function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { return { id: record.id, @@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { profileKind: "workspace-runtime", }); if (adoptedRecord) { - const record: RuntimeServiceRecord = { - id: row.id, - companyId: row.companyId, - projectId: row.projectId ?? null, - projectWorkspaceId: row.projectWorkspaceId ?? null, - executionWorkspaceId: row.executionWorkspaceId ?? null, - issueId: row.issueId ?? null, - serviceName: row.serviceName, - status: "running", - lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], - scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], - scopeId: row.scopeId ?? null, - reuseKey: row.reuseKey ?? null, - command: row.command ?? null, - cwd: row.cwd ?? null, - port: adoptedRecord.port ?? row.port ?? null, - url: adoptedRecord.url ?? row.url ?? null, - provider: "local_process", - providerRef: String(adoptedRecord.pid), - ownerAgentId: row.ownerAgentId ?? null, - startedByRunId: row.startedByRunId ?? null, - lastUsedAt: new Date().toISOString(), - startedAt: row.startedAt.toISOString(), - stoppedAt: null, - stopPolicy: (row.stopPolicy as Record | null) ?? null, - healthStatus: "healthy", - reused: true, - db, - child: null, - leaseRunIds: new Set(), - idleTimer: null, - envFingerprint: row.reuseKey ?? "", - serviceKey: adoptedRecord.serviceKey, - profileKind: "workspace-runtime", - processGroupId: adoptedRecord.processGroupId ?? null, - }; - registerRuntimeService(db, record); - await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { - runtimeServiceId: row.id, - lastSeenAt: record.lastUsedAt, - }); - await persistRuntimeServiceRecord(db, record); - adopted += 1; - continue; + const adoptedUrl = adoptedRecord.url ?? row.url ?? null; + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) { + await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); + } else { + const record: RuntimeServiceRecord = { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + serviceName: row.serviceName, + status: "running", + lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: adoptedRecord.port ?? row.port ?? null, + url: adoptedRecord.url ?? row.url ?? null, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: new Date().toISOString(), + startedAt: row.startedAt.toISOString(), + stoppedAt: null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: "healthy", + reused: true, + db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: row.reuseKey ?? "", + serviceKey: adoptedRecord.serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + registerRuntimeService(db, record); + await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { + runtimeServiceId: row.id, + lastSeenAt: record.lastUsedAt, + }); + await persistRuntimeServiceRecord(db, record); + adopted += 1; + continue; + } } const now = new Date(); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 296e3341..fae34f35 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -17,6 +17,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. + Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. @@ -59,6 +61,8 @@ If already checked out by you, returns normally. If owned by another agent: `409 **Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay. +If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, explicitly reflect the new comment context first, then fetch broader history only if needed. + Use comments incrementally: - if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}` diff --git a/tsconfig.json b/tsconfig.json index 3a989f38..9a5267db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "./packages/adapters/claude-local" }, { "path": "./packages/adapters/codex-local" }, { "path": "./packages/adapters/cursor-local" }, + { "path": "./packages/adapters/droid-local" }, { "path": "./packages/adapters/openclaw-gateway" }, { "path": "./packages/adapters/opencode-local" }, { "path": "./packages/adapters/pi-local" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f240defc..0bc4721b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,6 +34,7 @@ import { InstanceSettings } from "./pages/InstanceSettings"; import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings"; import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; +import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; @@ -175,6 +176,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> @@ -321,6 +323,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts new file mode 100644 index 00000000..fe809273 --- /dev/null +++ b/ui/src/adapters/adapter-display-registry.ts @@ -0,0 +1,157 @@ +/** + * Single source of truth for adapter display metadata. + * + * Built-in adapters have entries in `adapterDisplayMap`. External (plugin) + * adapters get sensible defaults derived from their type string via + * `getAdapterDisplay()`. + */ +import type { ComponentType } from "react"; +import { + Bot, + Code, + Gem, + MousePointer2, + Sparkles, + Terminal, + Cpu, +} from "lucide-react"; +import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; +import { HermesIcon } from "@/components/HermesIcon"; + +// --------------------------------------------------------------------------- +// Type suffix parsing +// --------------------------------------------------------------------------- + +const TYPE_SUFFIXES: Record = { + _local: "local", + _gateway: "gateway", +}; + +function getTypeSuffix(type: string): string | null { + for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) { + if (type.endsWith(suffix)) return mode; + } + return null; +} + +function withSuffix(label: string, suffix: string | null): string { + return suffix ? `${label} (${suffix})` : label; +} + +// --------------------------------------------------------------------------- +// Display metadata per adapter type +// --------------------------------------------------------------------------- + +export interface AdapterDisplayInfo { + label: string; + description: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; + comingSoon?: boolean; + disabledLabel?: string; +} + +const adapterDisplayMap: Record = { + claude_local: { + label: "Claude Code", + description: "Local Claude agent", + icon: Sparkles, + recommended: true, + }, + codex_local: { + label: "Codex", + description: "Local Codex agent", + icon: Code, + recommended: true, + }, + gemini_local: { + label: "Gemini CLI", + description: "Local Gemini agent", + icon: Gem, + }, + opencode_local: { + label: "OpenCode", + description: "Local multi-provider agent", + icon: OpenCodeLogoIcon, + }, + hermes_local: { + label: "Hermes Agent", + description: "Local Hermes CLI agent", + icon: HermesIcon, + }, + pi_local: { + label: "Pi", + description: "Local Pi agent", + icon: Terminal, + }, + cursor: { + label: "Cursor", + description: "Local Cursor agent", + icon: MousePointer2, + }, + openclaw_gateway: { + label: "OpenClaw Gateway", + description: "Invoke OpenClaw via gateway protocol", + icon: Bot, + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App", + }, + process: { + label: "Process", + description: "Internal process adapter", + icon: Cpu, + comingSoon: true, + }, + http: { + label: "HTTP", + description: "Internal HTTP adapter", + icon: Cpu, + comingSoon: true, + }, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +function humanizeType(type: string): string { + // Strip known type suffixes so "droid_local" → "Droid", not "Droid Local" + let base = type; + for (const suffix of Object.keys(TYPE_SUFFIXES)) { + if (base.endsWith(suffix)) { + base = base.slice(0, -suffix.length); + break; + } + } + return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function getAdapterLabel(type: string): string { + const base = adapterDisplayMap[type]?.label ?? humanizeType(type); + return withSuffix(base, getTypeSuffix(type)); +} + +export function getAdapterLabels(): Record { + const suffixed: Record = {}; + for (const [type, info] of Object.entries(adapterDisplayMap)) { + suffixed[type] = withSuffix(info.label, getTypeSuffix(type)); + } + return suffixed; +} + +export function getAdapterDisplay(type: string): AdapterDisplayInfo { + const known = adapterDisplayMap[type]; + if (known) return known; + + const suffix = getTypeSuffix(type); + const label = withSuffix(humanizeType(type), suffix); + return { + label, + description: suffix ? `External ${suffix} adapter` : "External adapter", + icon: Cpu, + }; +} + +export function isKnownAdapterType(type: string): boolean { + return type in adapterDisplayMap; +} diff --git a/ui/src/adapters/disabled-store.ts b/ui/src/adapters/disabled-store.ts new file mode 100644 index 00000000..66de3e71 --- /dev/null +++ b/ui/src/adapters/disabled-store.ts @@ -0,0 +1,33 @@ +/** + * Client-side store for disabled adapter types. + * + * Hydrated from the server's GET /api/adapters response. + * Provides synchronous reads so module-level constants can filter against it. + * Falls back to "nothing disabled" before the first hydration. + * + * Usage in components: + * useQuery + adaptersApi.list() populates the store automatically. + * + * Usage in non-React code: + * import { isAdapterTypeHidden } from "@/adapters/disabled-store"; + */ + +let disabledTypes = new Set(); + +/** Check if an adapter type is hidden from menus (sync read). */ +export function isAdapterTypeHidden(type: string): boolean { + return disabledTypes.has(type); +} + +/** Get all hidden adapter types (sync read). */ +export function getHiddenAdapterTypes(): Set { + return disabledTypes; +} + +/** + * Hydrate the store from a server response. + * Called by components that fetch the adapters list. + */ +export function setDisabledAdapterTypes(types: string[]): void { + disabledTypes = new Set(types); +} diff --git a/ui/src/adapters/dynamic-loader.ts b/ui/src/adapters/dynamic-loader.ts new file mode 100644 index 00000000..aec2efa8 --- /dev/null +++ b/ui/src/adapters/dynamic-loader.ts @@ -0,0 +1,122 @@ +/** + * Dynamic UI parser loading for external adapters. + * + * When the Paperclip UI encounters an adapter type that doesn't have a + * built-in parser (e.g., an external adapter loaded via the plugin system), + * it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and + * evaluates it to create a `parseStdoutLine` function. + * + * The parser module must export: + * - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]` + * - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers + * + * This is the bridge between the server-side plugin system and the client-side + * UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero + * runtime dependencies, and Paperclip's UI loads it on demand. + */ + +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types"; + +interface DynamicParserModule { + parseStdoutLine: StdoutLineParser; + createStdoutParser?: StdoutParserFactory; +} + +// Cache of dynamically loaded parsers by adapter type. +// Once loaded, the parser is reused for all runs of that adapter type. +const dynamicParserCache = new Map(); + +// Track which types we've already attempted to load (to avoid repeat 404s). +const failedLoads = new Set(); + +/** + * Dynamically load a UI parser for an adapter type from the server API. + * + * Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source + * in a scoped context, and extracts the `parseStdoutLine` export. + * + * @returns A StdoutLineParser function, or null if unavailable. + */ +export async function loadDynamicParser(adapterType: string): Promise { + // Return cached parser if already loaded + const cached = dynamicParserCache.get(adapterType); + if (cached) return cached; + + // Don't retry types that previously 404'd + if (failedLoads.has(adapterType)) return null; + + try { + const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`); + if (!response.ok) { + failedLoads.add(adapterType); + return null; + } + + const source = await response.text(); + + // Evaluate the module source using URL.createObjectURL + dynamic import(). + // This properly supports ESM modules with `export` statements. + // (new Function("exports", source) would fail with SyntaxError on `export` keywords.) + const blob = new Blob([source], { type: "application/javascript" }); + const blobUrl = URL.createObjectURL(blob); + + let parserModule: DynamicParserModule; + + try { + const mod = await import(/* @vite-ignore */ blobUrl); + + // Prefer the factory function (stateful parser) if available, + // fall back to the static parseStdoutLine function. + if (typeof mod.createStdoutParser === "function") { + const createStdoutParser = mod.createStdoutParser as StdoutParserFactory; + parserModule = { + createStdoutParser, + // Fallback for callers that only know about parseStdoutLine. + parseStdoutLine: + typeof mod.parseStdoutLine === "function" + ? (mod.parseStdoutLine as StdoutLineParser) + : ((line: string, ts: string) => { + const parser = createStdoutParser() as StatefulStdoutParser; + const entries = parser.parseLine(line, ts); + parser.reset(); + return entries; + }), + }; + } else if (typeof mod.parseStdoutLine === "function") { + parserModule = { + parseStdoutLine: mod.parseStdoutLine as StdoutLineParser, + }; + } else { + console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`); + failedLoads.add(adapterType); + return null; + } + } finally { + URL.revokeObjectURL(blobUrl); + } + + // Cache for reuse + dynamicParserCache.set(adapterType, parserModule); + console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`); + return parserModule; + } catch (err) { + console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err); + failedLoads.add(adapterType); + return null; + } +} + +/** + * Invalidate a cached dynamic parser, removing it from both the parser cache + * and the failed-loads set so that the next load attempt will try again. + */ +export function invalidateDynamicParser(adapterType: string): boolean { + const wasCached = dynamicParserCache.has(adapterType); + dynamicParserCache.delete(adapterType); + failedLoads.delete(adapterType); + if (wasCached) { + console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`); + } + return wasCached; +} diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx index 62b85fea..4b807043 100644 --- a/ui/src/adapters/hermes-local/config-fields.tsx +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -1,49 +1,49 @@ -import type { AdapterConfigFieldsProps } from "../types"; -import { - Field, - DraftInput, -} from "../../components/agent-config-primitives"; -import { ChoosePathButton } from "../../components/PathInstructionsModal"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; -const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; - -export function HermesLocalConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, - hideInstructionsFile, -}: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; - return ( - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
- ); -} +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function HermesLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts index 97c064f8..c037a747 100644 --- a/ui/src/adapters/hermes-local/index.ts +++ b/ui/src/adapters/hermes-local/index.ts @@ -1,12 +1,12 @@ -import type { UIAdapterModule } from "../types"; -import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; -import { HermesLocalConfigFields } from "./config-fields"; -import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; - -export const hermesLocalUIAdapter: UIAdapterModule = { - type: "hermes_local", - label: "Hermes Agent", - parseStdoutLine: parseHermesStdoutLine, - ConfigFields: HermesLocalConfigFields, - buildAdapterConfig: buildHermesConfig, -}; +import type { UIAdapterModule } from "../types"; +import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields"; +import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; + +export const hermesLocalUIAdapter: UIAdapterModule = { + type: "hermes_local", + label: "Hermes Agent", + parseStdoutLine: parseHermesStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, +}; diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index feb04511..5623cf8a 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -1,4 +1,12 @@ -export { getUIAdapter, listUIAdapters } from "./registry"; +export { + getUIAdapter, + listUIAdapters, + findUIAdapter, + registerUIAdapter, + unregisterUIAdapter, + syncExternalAdapters, + onAdapterChange, +} from "./registry"; export { buildTranscript } from "./transcript"; export type { TranscriptEntry, diff --git a/ui/src/adapters/metadata.test.ts b/ui/src/adapters/metadata.test.ts new file mode 100644 index 00000000..70b7ef3c --- /dev/null +++ b/ui/src/adapters/metadata.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isEnabledAdapterType, listAdapterOptions } from "./metadata"; +import type { UIAdapterModule } from "./types"; + +const externalAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("adapter metadata", () => { + it("treats registered external adapters as enabled by default", () => { + expect(isEnabledAdapterType("external_test")).toBe(true); + + expect( + listAdapterOptions((type) => type, [externalAdapter]), + ).toEqual([ + { + value: "external_test", + label: "external_test", + comingSoon: false, + hidden: false, + }, + ]); + }); + + it("keeps intentionally withheld built-in adapters marked as coming soon", () => { + expect(isEnabledAdapterType("process")).toBe(false); + expect(isEnabledAdapterType("http")).toBe(false); + }); +}); \ No newline at end of file diff --git a/ui/src/adapters/metadata.ts b/ui/src/adapters/metadata.ts new file mode 100644 index 00000000..297a7237 --- /dev/null +++ b/ui/src/adapters/metadata.ts @@ -0,0 +1,75 @@ +/** + * Adapter metadata utilities — built on top of the display registry and UI adapter list. + * + * This module bridges the static display metadata with the dynamic adapter registry. + * "Coming soon" status is derived from the display registry's `comingSoon` flag. + * "Hidden" status comes from the disabled-adapter store (server-side toggle). + */ +import type { UIAdapterModule } from "./types"; +import { listUIAdapters } from "./registry"; +import { isAdapterTypeHidden } from "./disabled-store"; +import { getAdapterLabel, getAdapterDisplay } from "./adapter-display-registry"; + +export interface AdapterOptionMetadata { + value: string; + label: string; + comingSoon: boolean; + hidden: boolean; +} + +export function listKnownAdapterTypes(): string[] { + return listUIAdapters().map((adapter) => adapter.type); +} + +/** + * Check whether an adapter type is enabled (not "coming soon"). + * Unknown types (external adapters) are always considered enabled. + */ +export function isEnabledAdapterType(type: string): boolean { + // Check display registry first — built-in adapters like process/http are + // intentionally withheld even though they're registered as UI adapters. + if (getAdapterDisplay(type).comingSoon) return false; + // All other types (registered or external) are enabled. + return true; +} + +/** + * Check whether an adapter type is a valid choice for new agent creation. + * Includes all registered UI adapters (built-in + external) and + * any non-"coming soon" adapter from the display registry. + */ +export function isValidAdapterType(type: string): boolean { + if (getAdapterDisplay(type).comingSoon) return false; + return true; +} + +/** + * Build option metadata for a list of adapters (for dropdowns). + * `labelFor` callback allows callers to override labels; defaults to display registry. + */ +export function listAdapterOptions( + labelFor?: (type: string) => string, + adapters: UIAdapterModule[] = listUIAdapters(), +): AdapterOptionMetadata[] { + const getLabel = labelFor ?? getAdapterLabel; + return adapters.map((adapter) => ({ + value: adapter.type, + label: getLabel(adapter.type), + comingSoon: !!getAdapterDisplay(adapter.type).comingSoon, + hidden: isAdapterTypeHidden(adapter.type), + })); +} + +/** + * List UI adapters excluding those hidden via the Adapters settings page. + */ +export function listVisibleUIAdapters(): UIAdapterModule[] { + return listUIAdapters().filter((a) => !isAdapterTypeHidden(a.type)); +} + +/** + * List visible adapter types (for non-React contexts like module-level constants). + */ +export function listVisibleAdapterTypes(): string[] { + return listVisibleUIAdapters().map((a) => a.type); +} diff --git a/ui/src/adapters/registry.test.ts b/ui/src/adapters/registry.test.ts new file mode 100644 index 00000000..6d30f6b0 --- /dev/null +++ b/ui/src/adapters/registry.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import type { UIAdapterModule } from "./types"; +import { + findUIAdapter, + getUIAdapter, + listUIAdapters, + registerUIAdapter, + unregisterUIAdapter, +} from "./registry"; +import { processUIAdapter } from "./process"; +import { SchemaConfigFields } from "./schema-config-fields"; + +const externalUIAdapter: UIAdapterModule = { + type: "external_test", + label: "External Test", + parseStdoutLine: () => [], + ConfigFields: () => null, + buildAdapterConfig: () => ({}), +}; + +describe("ui adapter registry", () => { + beforeEach(() => { + unregisterUIAdapter("external_test"); + }); + + afterEach(() => { + unregisterUIAdapter("external_test"); + }); + + it("registers adapters for lookup and listing", () => { + registerUIAdapter(externalUIAdapter); + + expect(findUIAdapter("external_test")).toBe(externalUIAdapter); + expect(getUIAdapter("external_test")).toBe(externalUIAdapter); + expect(listUIAdapters().some((adapter) => adapter.type === "external_test")).toBe(true); + }); + + it("falls back to the process parser for unknown types after unregistering", () => { + registerUIAdapter(externalUIAdapter); + + unregisterUIAdapter("external_test"); + + expect(findUIAdapter("external_test")).toBeNull(); + const fallback = getUIAdapter("external_test"); + // Unknown types return a lazy-loading wrapper (for external adapters), + // not the process adapter directly. The type is preserved. + expect(fallback.type).toBe("external_test"); + // But it uses the schema-based config fields for external adapter forms. + expect(fallback.ConfigFields).toBe(SchemaConfigFields); + }); +}); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 67d89ada..e418457e 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,32 +3,256 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; -import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; +import { hermesLocalUIAdapter } from "./hermes-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; +import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader"; +import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields"; -const uiAdapters: UIAdapterModule[] = [ - claudeLocalUIAdapter, - codexLocalUIAdapter, - geminiLocalUIAdapter, - hermesLocalUIAdapter, - openCodeLocalUIAdapter, - piLocalUIAdapter, - cursorLocalUIAdapter, - openClawGatewayUIAdapter, - processUIAdapter, - httpUIAdapter, -]; +const uiAdapters: UIAdapterModule[] = []; +const adaptersByType = new Map(); -const adaptersByType = new Map( - uiAdapters.map((a) => [a.type, a]), -); +// Types registered at module load time — allowed to be overridden by +// external adapters that ship their own ui-parser.js via the server. +const builtinTypes = new Set(); + +// Original builtin adapters stored for restoration when external overrides +// are deactivated or removed. +const builtinAdaptersByType = new Map(); + +// Tracks which builtin types currently have an active external override. +const activeExternalOverrides = new Set(); + +// Generation counter to discard stale dynamic parser loads. When an override +// is deactivated while a load is in-flight, the generation is bumped and the +// stale result is discarded in its .then() handler. +const overrideGeneration = new Map(); + +// Subscriber list — components can register to be notified when adapters change +// (e.g., when a dynamic parser replaces a placeholder). +const adapterChangeListeners = new Set<() => void>(); + +/** Subscribe to adapter registry changes. Returns unsubscribe function. */ +export function onAdapterChange(fn: () => void): () => void { + adapterChangeListeners.add(fn); + return () => adapterChangeListeners.delete(fn); +} + +function notifyAdapterChange(): void { + for (const fn of adapterChangeListeners) fn(); +} + +function registerBuiltInUIAdapters() { + for (const adapter of [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + geminiLocalUIAdapter, + hermesLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ]) { + builtinTypes.add(adapter.type); + builtinAdaptersByType.set(adapter.type, adapter); + registerUIAdapter(adapter); + } +} + +export function registerUIAdapter(adapter: UIAdapterModule): void { + const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1, adapter); + } else { + uiAdapters.push(adapter); + } + adaptersByType.set(adapter.type, adapter); + notifyAdapterChange(); +} + +export function unregisterUIAdapter(type: string): void { + if (type === processUIAdapter.type || type === httpUIAdapter.type) return; + const existingIndex = uiAdapters.findIndex((entry) => entry.type === type); + if (existingIndex >= 0) { + uiAdapters.splice(existingIndex, 1); + } + adaptersByType.delete(type); +} + +export function findUIAdapter(type: string): UIAdapterModule | null { + return adaptersByType.get(type) ?? null; +} + +registerBuiltInUIAdapters(); export function getUIAdapter(type: string): UIAdapterModule { - return adaptersByType.get(type) ?? processUIAdapter; + const builtIn = adaptersByType.get(type); + + if (!builtIn) { + let loadStarted = false; + return { + type, + label: type, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parserModule) => { + if (parserModule) { + registerUIAdapter({ + type, + label: type, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, + }); + } + }); + } + return processUIAdapter.parseStdoutLine(line, ts); + }, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildSchemaAdapterConfig, + }; + } + + return builtIn; +} + +/** + * Keep the UI adapter registry in sync with the server's adapter list. + * + * Two concerns: + * + * 1. **Builtin overrides** — when an external adapter ships a ui-parser.js for a + * builtin type, the external parser takes priority. When the external is + * disabled or removed the original builtin parser is restored transparently. + * A generation counter guards against stale loads that resolve after the + * override has been torn down. + * + * 2. **Non-builtin externals** — register a bridge adapter that lazily loads the + * dynamic parser on first stdout line, falling back to the generic process + * adapter. Once the parser resolves the bridge is replaced. + */ +export function syncExternalAdapters( + serverAdapters: { + type: string; + label: string; + disabled?: boolean; + /** When true, the external override for a builtin type is client-side paused. */ + overrideDisabled?: boolean; + }[], +): void { + const enabledExternalTypes = new Set( + serverAdapters.filter((a) => !a.disabled && !a.overrideDisabled).map((a) => a.type), + ); + const allExternalTypes = new Set( + serverAdapters.map((a) => a.type), + ); + + // ── Builtin override lifecycle ────────────────────────────────────────── + + for (const builtinType of builtinTypes) { + const originalBuiltin = builtinAdaptersByType.get(builtinType); + if (!originalBuiltin) continue; + + const hasExternal = allExternalTypes.has(builtinType); + const externalEnabled = enabledExternalTypes.has(builtinType); + const wasOverridden = activeExternalOverrides.has(builtinType); + + if (hasExternal && externalEnabled && !wasOverridden) { + // Activate: external just became active → replace builtin with bridge. + activeExternalOverrides.add(builtinType); + + const gen = (overrideGeneration.get(builtinType) ?? 0) + 1; + overrideGeneration.set(builtinType, gen); + + let loadStarted = false; + const fallbackParser = originalBuiltin.parseStdoutLine; + const externalEntry = serverAdapters.find((a) => a.type === builtinType); + const label = externalEntry?.label ?? builtinType; + + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(builtinType).then((parserModule) => { + // Discard if the override was torn down while the load was in-flight. + if (parserModule && overrideGeneration.get(builtinType) === gen) { + registerUIAdapter({ + type: builtinType, + label, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } + }); + } + return fallbackParser(line, ts); + }, + ConfigFields: originalBuiltin.ConfigFields, + buildAdapterConfig: originalBuiltin.buildAdapterConfig, + }); + } else if ((!hasExternal || !externalEnabled) && wasOverridden) { + // Deactivate: external disabled or removed → restore builtin. + activeExternalOverrides.delete(builtinType); + overrideGeneration.delete(builtinType); + invalidateDynamicParser(builtinType); + registerUIAdapter(originalBuiltin); + } + } + + // ── Non-builtin externals ─────────────────────────────────────────────── + + for (const { type, label } of serverAdapters) { + if (builtinTypes.has(type)) continue; // handled above + + const existing = adaptersByType.get(type); + + // If this type already has an externally-loaded dynamic parser, skip — + // it was loaded from disk on a previous sync. Only re-trigger loading + // when the server returns a new external adapter that hasn't been loaded yet. + if (existing && existing !== processUIAdapter) continue; + + let loadStarted = false; + // Use the existing built-in parser as fallback (if any) so we don't + // regress to the generic process parser while the dynamic one loads. + const fallbackParser = existing?.parseStdoutLine ?? processUIAdapter.parseStdoutLine; + + registerUIAdapter({ + type, + label, + parseStdoutLine: (line: string, ts: string) => { + if (!loadStarted) { + loadStarted = true; + loadDynamicParser(type).then((parserModule) => { + if (parserModule) { + registerUIAdapter({ + type, + label, + parseStdoutLine: parserModule.parseStdoutLine, + createStdoutParser: parserModule.createStdoutParser, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, + }); + } + }); + } + return fallbackParser(line, ts); + }, + ConfigFields: existing?.ConfigFields ?? SchemaConfigFields, + buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig, + }); + } } export function listUIAdapters(): UIAdapterModule[] { diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx new file mode 100644 index 00000000..7161f9e0 --- /dev/null +++ b/ui/src/adapters/schema-config-fields.tsx @@ -0,0 +1,507 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; + +import type { AdapterConfigFieldsProps } from "./types"; +import { + Field, + DraftInput, + DraftNumberInput, + DraftTextarea, + ToggleField, +} from "../components/agent-config-primitives"; +import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; +import { ChevronDown } from "lucide-react"; + +// ── Select field (extracted to keep hooks at component top level) ────── +function SelectField({ + value, + options, + onChange, +}: { + value: string; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const selectedOpt = options.find((o) => o.value === value); + return ( + + + + + + {options.map((opt) => ( + + ))} + + + ); +} +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + + +// --------------------------------------------------------------------------- +// Combobox: type-to-filter dropdown with free text fallback +// --------------------------------------------------------------------------- + +function ComboboxField({ + value, + options, + onChange, + placeholder, +}: { + value: string; + options: { label: string; value: string; group?: string }[]; + onChange: (val: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const inputRef = useRef(null); + + // Sync filter with external value when it changes (e.g. provider switch resets model) + useEffect(() => { + setFilter(""); + }, [value]); + + const filtered = options.filter((opt) => { + if (!filter) return true; + const q = filter.toLowerCase(); + return ( + opt.value.toLowerCase().includes(q) || + opt.label.toLowerCase().includes(q) || + (opt.group && opt.group.toLowerCase().includes(q)) + ); + }); + + const selectedOpt = options.find((o) => o.value === value); + const displayValue = filter || selectedOpt?.value || value || ""; + + // Group filtered options by `group` field if present + const grouped = new Map(); + for (const opt of filtered) { + const g = opt.group ?? ""; + if (!grouped.has(g)) grouped.set(g, []); + grouped.get(g)!.push(opt); + } + + const select = useCallback( + (val: string) => { + onChange(val); + setOpen(false); + setFilter(""); + inputRef.current?.blur(); + }, + [onChange], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // If exactly one match, select it. Otherwise commit the typed value. + if (filtered.length === 1) { + select(filtered[0].value); + } else if (filter) { + select(filter); + } + } else if (e.key === "Escape") { + setOpen(false); + setFilter(""); + } else if (e.key === "ArrowDown" && !open) { + e.preventDefault(); + setOpen(true); + } + }; + + return ( +
+
+ { + setFilter(e.target.value); + if (!open) setOpen(true); + }} + onFocus={() => { + if (!open) setOpen(true); + }} + onBlur={() => { + // Delay close to allow click on option to register + setTimeout(() => setOpen(false), 150); + }} + onKeyDown={handleKeyDown} + /> + 0} onOpenChange={setOpen}> + + + + e.preventDefault()} + > + {Array.from(grouped.entries()).map(([group, opts]) => ( +
+ {group && ( +
+ {group} +
+ )} + {opts.map((opt) => ( + + ))} +
+ ))} + {filter && filtered.length === 0 && ( +
+ Use "{filter}" as custom value (press Enter) +
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// SchemaConfigFields component +// --------------------------------------------------------------------------- + +const schemaCache = new Map(); +const schemaFetchInflight = new Map>(); +const failedSchemaTypes = new Set(); + +async function fetchConfigSchema(adapterType: string): Promise { + const cached = schemaCache.get(adapterType); + if (cached !== undefined) return cached; + if (failedSchemaTypes.has(adapterType)) return null; + + const inflight = schemaFetchInflight.get(adapterType); + if (inflight) return inflight; + + const promise = (async () => { + try { + const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`); + if (!res.ok) { + failedSchemaTypes.add(adapterType); + return null; + } + const schema = (await res.json()) as AdapterConfigSchema; + schemaCache.set(adapterType, schema); + return schema; + } catch { + failedSchemaTypes.add(adapterType); + return null; + } finally { + schemaFetchInflight.delete(adapterType); + } + })(); + + schemaFetchInflight.set(adapterType, promise); + return promise; +} + +export function invalidateConfigSchemaCache(adapterType: string): void { + schemaCache.delete(adapterType); + failedSchemaTypes.delete(adapterType); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +function useConfigSchema(adapterType: string): AdapterConfigSchema | null { + const [schema, setSchema] = useState( + schemaCache.get(adapterType) ?? null, + ); + + useEffect(() => { + let cancelled = false; + fetchConfigSchema(adapterType).then((s) => { + if (!cancelled) setSchema(s); + }); + return () => { + cancelled = true; + }; + }, [adapterType]); + + return schema; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getDefaultValue(field: ConfigFieldSchema): unknown { + if (field.default !== undefined) return field.default; + switch (field.type) { + case "toggle": + return false; + case "number": + return 0; + case "text": + case "textarea": + return ""; + case "select": + return field.options?.[0]?.value ?? ""; + } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SchemaConfigFields({ + adapterType, + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const schema = useConfigSchema(adapterType); + + const [defaultsApplied, setDefaultsApplied] = useState(false); + useEffect(() => { + if (!schema || !isCreate || defaultsApplied) return; + const defaults: Record = {}; + for (const field of schema.fields) { + const def = getDefaultValue(field); + if (def !== undefined && def !== "") { + defaults[field.key] = def; + } + } + if (Object.keys(defaults).length > 0) { + set?.({ + adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults }, + }); + } + setDefaultsApplied(true); + }, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]); + + if (!schema || schema.fields.length === 0) return null; + + function readValue(field: ConfigFieldSchema): unknown { + if (isCreate) { + return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field); + } + const stored = config[field.key]; + return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string); + } + + function writeValue(field: ConfigFieldSchema, value: unknown): void { + if (isCreate) { + const next = { + adapterSchemaValues: { + ...values?.adapterSchemaValues, + [field.key]: value, + }, + }; + + // When provider changes, auto-clear model if it's not in the new provider's list + if (field.key === "provider" && schema) { + const modelField = schema.fields.find((f) => f.key === "model"); + if (modelField?.meta?.providerModels) { + const modelsByProvider = modelField.meta.providerModels as Record; + const providerModels = modelsByProvider[String(value)] ?? []; + const currentModel = values?.adapterSchemaValues?.model; + if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { + next.adapterSchemaValues.model = ""; + } + } + } + + set?.(next); + } else { + mark("adapterConfig", field.key, value); + + // Same logic for edit mode + if (field.key === "provider" && schema) { + const modelField = schema.fields.find((f) => f.key === "model"); + if (modelField?.meta?.providerModels) { + const modelsByProvider = modelField.meta.providerModels as Record; + const providerModels = modelsByProvider[String(value)] ?? []; + const currentModel = eff("adapterConfig", "model", ""); + if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { + mark("adapterConfig", "model", ""); + } + } + } + } + } + + return ( + <> + {schema.fields.map((field) => { + switch (field.type) { + case "select": { + const currentVal = String(readValue(field) ?? ""); + return ( + + writeValue(field, v)} + /> + + ); + } + + case "toggle": + return ( + writeValue(field, v)} + /> + ); + + case "number": + return ( + + writeValue(field, v)} + immediate + className={inputClass} + /> + + ); + + case "textarea": + return ( + + writeValue(field, v || undefined)} + immediate + /> + + ); + + case "combobox": { + const currentVal = String(readValue(field) ?? ""); + // Dynamic options: if meta.providerModels exists, compute options + // based on the current provider value + let comboboxOptions = field.options ?? []; + if (field.meta?.providerModels) { + const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto"); + const modelsByProvider = field.meta.providerModels as Record; + if (providerVal === "auto") { + // Auto: show all models from all providers, grouped by provider + const providerLabel = schema.fields.find((f) => f.key === "provider"); + const providerOptions = providerLabel?.options ?? []; + comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) => + models.map((m) => ({ + label: m, + value: m, + group: providerOptions.find((p) => p.value === prov)?.label ?? prov, + })), + ); + } else { + const providerModels = modelsByProvider[providerVal] ?? []; + const providerLabel = schema.fields.find((f) => f.key === "provider"); + const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal; + comboboxOptions = providerModels.map((m) => ({ + label: m, + value: m, + group: provName, + })); + } + } + return ( + + writeValue(field, v || undefined)} + placeholder={field.hint} + /> + + ); + } + + case "text": + default: + return ( + + writeValue(field, v || undefined)} + immediate + className={inputClass} + /> + + ); + } + })} + + ); +} + +// --------------------------------------------------------------------------- +// Build adapter config from schema values + standard CreateConfigValues fields +// --------------------------------------------------------------------------- + +export function buildSchemaAdapterConfig( + values: CreateConfigValues, +): Record { + const ac: Record = {}; + + if (values.model?.trim()) ac.model = values.model.trim(); + if (values.cwd) ac.cwd = values.cwd; + if (values.command) ac.command = values.command; + if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath; + if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort; + + if (values.extraArgs) { + ac.extraArgs = values.extraArgs + .split(/\s+/) + .filter(Boolean); + } + + if (values.adapterSchemaValues) { + Object.assign(ac, values.adapterSchemaValues); + } + + return ac; +} diff --git a/ui/src/adapters/transcript.test.ts b/ui/src/adapters/transcript.test.ts index 8b56163e..c33c9008 100644 --- a/ui/src/adapters/transcript.test.ts +++ b/ui/src/adapters/transcript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildTranscript, type RunLogChunk } from "./transcript"; +import type { UIAdapterModule } from "./types"; describe("buildTranscript", () => { const ts = "2026-03-20T13:00:00.000Z"; @@ -27,4 +28,46 @@ describe("buildTranscript", () => { { kind: "stderr", ts, text: "stderr /Users/d****/project" }, ]); }); + + it("creates a fresh stateful parser for each transcript build", () => { + const statefulAdapter: UIAdapterModule = { + type: "stateful_test", + label: "Stateful Test", + parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], + createStdoutParser: () => { + let pending: string | null = null; + return { + parseLine: (line, entryTs) => { + if (line.startsWith("begin:")) { + pending = line.slice("begin:".length); + return []; + } + if (line === "finish" && pending) { + const text = `completed:${pending}`; + pending = null; + return [{ kind: "stdout", ts: entryTs, text }]; + } + return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }]; + }, + reset: () => { + pending = null; + }, + }; + }, + ConfigFields: () => null, + buildAdapterConfig: () => ({}), + }; + + const first = buildTranscript( + [{ ts, stream: "stdout", chunk: "begin:task-a\n" }], + statefulAdapter, + ); + const second = buildTranscript( + [{ ts, stream: "stdout", chunk: "finish\n" }], + statefulAdapter, + ); + + expect(first).toEqual([]); + expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 98b19454..307aa5ae 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,9 +1,20 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; -import type { TranscriptEntry, StdoutLineParser } from "./types"; +import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types"; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; +function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) { + if (typeof source === "function") { + return { parseLine: source, reset: null as (() => void) | null }; + } + if (source.createStdoutParser) { + const parser = source.createStdoutParser(); + return { parseLine: parser.parseLine, reset: parser.reset }; + } + return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null }; +} + export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; @@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr export function buildTranscript( chunks: RunLogChunk[], - parser: StdoutLineParser, + parserSource: StdoutLineParser | TranscriptParserSource, opts?: TranscriptBuildOptions, ): TranscriptEntry[] { const entries: TranscriptEntry[] = []; let stdoutBuffer = ""; const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false }; + const { parseLine, reset } = resolveStdoutParser(parserSource); for (const chunk of chunks) { if (chunk.stream === "stderr") { @@ -47,15 +59,17 @@ export function buildTranscript( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); } + reset?.(); + return entries; } diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts index 6a7ae48a..74bd48e5 100644 --- a/ui/src/adapters/types.ts +++ b/ui/src/adapters/types.ts @@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils"; // Re-export shared types so local consumers don't need to change imports export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils"; +export interface StatefulStdoutParser { + parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; + reset: () => void; +} + +export type StdoutParserFactory = () => StatefulStdoutParser; + +export interface TranscriptParserSource { + parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; + createStdoutParser?: StdoutParserFactory; +} + export interface AdapterConfigFieldsProps { mode: "create" | "edit"; isCreate: boolean; @@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps { hideInstructionsFile?: boolean; } -export interface UIAdapterModule { +export interface UIAdapterModule extends TranscriptParserSource { type: string; label: string; - parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[]; ConfigFields: ComponentType; buildAdapterConfig: (values: CreateConfigValues) => Record; } diff --git a/ui/src/adapters/use-disabled-adapters.ts b/ui/src/adapters/use-disabled-adapters.ts new file mode 100644 index 00000000..ebc63946 --- /dev/null +++ b/ui/src/adapters/use-disabled-adapters.ts @@ -0,0 +1,54 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi } from "@/api/adapters"; +import { setDisabledAdapterTypes } from "@/adapters/disabled-store"; +import { syncExternalAdapters } from "@/adapters/registry"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Fetch adapters and keep the disabled-adapter store + UI adapter registry + * in sync with the server. + * + * - Registers external adapter types in the UI registry so they appear in + * dropdowns (done eagerly during render — idempotent, no React state). + * - Syncs the disabled-adapter store for non-React consumers (useEffect). + * + * Returns a reactive Set of disabled types for use as useMemo dependencies. + * Call this at the top of any component that renders adapter menus. + */ +export function useDisabledAdaptersSync(): Set { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + // Eagerly register external adapter types in the UI registry so that + // consumers calling listUIAdapters() in the same render cycle see them. + // This is idempotent — already-registered types are skipped. + if (adapters) { + syncExternalAdapters( + adapters + .filter((a) => a.source === "external") + .map((a) => ({ + type: a.type, + label: a.label, + disabled: a.disabled, + overrideDisabled: a.overridePaused, + })), + ); + } + + // Sync the disabled set to the global store for non-React code + useEffect(() => { + if (!adapters) return; + setDisabledAdapterTypes( + adapters.filter((a) => a.disabled).map((a) => a.type), + ); + }, [adapters]); + + return useMemo( + () => new Set(adapters?.filter((a) => a.disabled).map((a) => a.type) ?? []), + [adapters], + ); +} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts new file mode 100644 index 00000000..86705bd4 --- /dev/null +++ b/ui/src/api/adapters.ts @@ -0,0 +1,59 @@ +/** + * @fileoverview Frontend API client for external adapter management. + */ + +import { api } from "./client"; + +export interface AdapterInfo { + type: string; + label: string; + source: "builtin" | "external"; + modelsCount: number; + loaded: boolean; + disabled: boolean; + /** Installed version (for external npm adapters) */ + version?: string; + /** Package name (for external adapters) */ + packageName?: string; + /** Whether the adapter was installed from a local path (vs npm). */ + isLocalPath?: 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; +} + +export interface AdapterInstallResult { + type: string; + packageName: string; + version?: string; + installedAt: string; +} + +export const adaptersApi = { + /** List all registered adapters (built-in + external). */ + list: () => api.get("/adapters"), + + /** Install an external adapter from npm or a local path. */ + install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => + api.post("/adapters/install", params), + + /** Remove an external adapter by type. */ + remove: (type: string) => api.delete<{ type: string; removed: boolean }>(`/adapters/${type}`), + + /** Enable or disable an adapter (disabled adapters hidden from agent menus). */ + setDisabled: (type: string, disabled: boolean) => + api.patch<{ type: string; disabled: boolean; changed: boolean }>(`/adapters/${type}`, { disabled }), + + /** Pause or resume an external override of a builtin type. */ + setOverridePaused: (type: string, paused: boolean) => + api.patch<{ type: string; paused: boolean; changed: boolean }>(`/adapters/${type}/override`, { paused }), + + /** Reload an external adapter (bust server + client caches). */ + reload: (type: string) => + api.post<{ type: string; version?: string; reloaded: boolean }>(`/adapters/${type}/reload`, {}), + + /** Reinstall an npm-sourced adapter (pulls latest from registry, then reloads). */ + reinstall: (type: string) => + api.post<{ type: string; version?: string; reinstalled: boolean }>(`/adapters/${type}/reinstall`, {}), +}; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index bda8bf7a..fcd38604 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -32,6 +32,7 @@ export interface DetectedAdapterModel { model: string; provider: string; source: string; + candidates?: string[]; } export interface ClaudeLoginResult { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c3c9bdfa..6b4f761b 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { Agent, AdapterEnvironmentTestResult, @@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { ReportsToPicker } from "./ReportsToPicker"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; +import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; /* ---- Create mode values ---- */ @@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); + // Sync disabled adapter types from server so dropdown filters them out + const disabledTypes = useDisabledAdaptersSync(); + const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), @@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const isLocal = - adapterType === "claude_local" || - adapterType === "codex_local" || - adapterType === "gemini_local" || - adapterType === "hermes_local" || - adapterType === "opencode_local" || - adapterType === "pi_local" || - adapterType === "cursor"; - const isHermesLocal = adapterType === "hermes_local"; + const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); + const isLocal = !NONLOCAL_TYPES.has(adapterType); + const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : ["agents", "none", "detect-model", adapterType], queryFn: () => { if (!selectedCompanyId) { - throw new Error("Select a company to detect the Hermes model"); + throw new Error("Select a company to detect the model"); } return agentsApi.detectModel(selectedCompanyId, adapterType); }, - enabled: Boolean(selectedCompanyId && isHermesLocal), + enabled: Boolean(selectedCompanyId && isLocal), }); const detectedModel = detectedModelData?.model ?? null; + const detectedModelCandidates = detectedModelData?.candidates ?? []; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { { if (isCreate) { // Reset all adapter-specific fields to defaults when switching adapter type @@ -692,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* Adapter-specific fields */} - + {/* Adapter-specific fields are rendered inside Permissions & Configuration */} @@ -716,24 +716,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { onCommit={(v) => isCreate ? set!({ command: v }) - : mark("adapterConfig", "command", v || undefined) + : mark("adapterConfig", "command", v || null) } immediate className={inputClass} placeholder={ - adapterType === "codex_local" - ? "codex" - : adapterType === "gemini_local" - ? "gemini" - : adapterType === "hermes_local" - ? "hermes" - : adapterType === "pi_local" - ? "pi" - : adapterType === "cursor" - ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude" + ({ + claude_local: "claude", + codex_local: "codex", + gemini_local: "gemini", + pi_local: "pi", + cursor: "agent", + opencode_local: "opencode", + } as Record)[adapterType] ?? adapterType.replace(/_local$/, "") } /> @@ -748,18 +743,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} - required={adapterType === "opencode_local" || adapterType === "hermes_local"} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"} - creatable={adapterType === "hermes_local"} - detectedModel={adapterType === "hermes_local" ? detectedModel : null} - onDetectModel={adapterType === "hermes_local" - ? async () => { - const result = await refetchDetectedModel(); - return result.data?.model ?? null; - } - : undefined} - detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} + creatable + detectedModel={detectedModel} + detectedModelCandidates={[]} + onDetectModel={async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + }} + detectModelLabel="Detect model" + emptyDetectHint="No model detected. Select or enter one manually." /> {fetchedModelsError && (

@@ -820,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {adapterType === "claude_local" && ( )} + isCreate ? set!({ extraArgs: v }) - : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined) + : mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : null) } immediate className={inputClass} @@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); - -/** Display list includes all real adapter types plus UI-only coming-soon entries. */ -const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ - ...AGENT_ADAPTER_TYPES.map((t) => ({ - value: t, - label: adapterLabels[t] ?? t, - comingSoon: !ENABLED_ADAPTER_TYPES.has(t), - })), -]; - function AdapterTypeDropdown({ value, onChange, + disabledTypes, }: { value: string; onChange: (type: string) => void; + disabledTypes: Set; }) { + const [open, setOpen] = useState(false); + const adapterList = useMemo( + () => + listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter( + (item) => !disabledTypes.has(item.value), + ), + [disabledTypes], + ); + return ( - + - {ADAPTER_DISPLAY_LIST.map((item) => ( + {adapterList.map((item) => ( )} - {onDetectModel && !detectedModel && !modelSearch.trim() && ( + {onDetectModel && !modelSearch.trim() && ( )} - {value && !models.some((m) => m.id === value) && ( + {value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && ( )} + {detectedModelCandidates + ?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value) + .map((candidate) => { + const entry = models.find((m) => m.id === candidate); + return ( + + ); + })}

{allowDefault && (
diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 8ba65c60..aa972337 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -120,4 +120,28 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("replaces the composer with a warning when comments are disabled", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + expect(container.textContent).toContain("Workspace is closed."); + expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull(); + expect(container.textContent).not.toContain("Comment"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b0b5c618..79f58702 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -78,6 +78,7 @@ interface CommentThreadProps { mentions?: MentionOption[]; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + composerDisabledReason?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -569,6 +570,7 @@ export function CommentThread({ mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, + composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -796,90 +798,96 @@ export function CommentThread({ )} -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - + {composerDisabledReason ? ( +
+ {composerDisabledReason}
-
+ ) : ( +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
+ )}
); diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index f0547684..78c09562 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} - + Archive {workspaceName} and clean up any owned workspace artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. {readinessQuery.isLoading ? ( -
- +
+ Checking whether this workspace is safe to close...
) : readinessQuery.error ? ( -
+
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
) : readiness ? ( -
-
+
+
{readiness.state === "blocked" ? "Close is blocked" @@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({ {blockingIssues.length > 0 ? (
-

Blocking issues

-
+

Blocking issues

+
{blockingIssues.map((issue) => ( -
+
{issue.identifier ?? issue.id} · {issue.title} @@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({ {readiness.blockingReasons.length > 0 ? (
-

Blocking reasons

-
- + aria-label="Toggle keyboard shortcuts" + />
@@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {

+ +
+
+
+

Sign out

+

+ Sign out of this Paperclip instance. You will be redirected to the login page. +

+
+ +
+
); } diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 6d412aa8..c2b0fe02 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; type JoinType = "human" | "agent"; const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES]; -const adapterLabels: Record = { - claude_local: "Claude (local)", - codex_local: "Codex (local)", - gemini_local: "Gemini CLI (local)", - opencode_local: "OpenCode (local)", - pi_local: "Pi (local)", - openclaw_gateway: "OpenClaw Gateway", - cursor: "Cursor (local)", - hermes_local: "Hermes Agent", - process: "Process", - http: "HTTP", -}; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); function dateTime(value: string) { return new Date(value).toLocaleString(); @@ -279,7 +268,7 @@ export function InviteLandingPage() { > {joinAdapterOptions.map((type) => ( ))} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d1f16ab7..d16a3103 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -71,8 +71,16 @@ import { SlidersHorizontal, Trash2, } from "lucide-react"; -import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; +import { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ActivityEvent, + type Agent, + type FeedbackVote, + type Issue, + type IssueAttachment, + type IssueComment, +} from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { @@ -306,6 +314,12 @@ export function IssueDetail() { enabled: !!issueId, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; + const commentComposerDisabledReason = useMemo(() => { + if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { + return null; + } + return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); + }, [issue?.currentExecutionWorkspace]); const { data: comments } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), @@ -1522,6 +1536,7 @@ export function IssueDetail() { await interruptQueuedComment.mutateAsync(runId); }} interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} + composerDisabledReason={commentComposerDisabledReason} onVote={async (commentId, vote, options) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment", diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 69415db6..9b1dd12c 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -19,7 +19,9 @@ import { cn, agentUrl } from "../lib/utils"; import { roleLabels } from "../components/agent-config-primitives"; import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; import { defaultCreateValues } from "../components/agent-config-defaults"; -import { getUIAdapter } from "../adapters"; +import { getUIAdapter, listUIAdapters } from "../adapters"; +import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { isValidAdapterType } from "../adapters/metadata"; import { ReportsToPicker } from "../components/ReportsToPicker"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, @@ -28,17 +30,6 @@ import { import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; -const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set([ - "claude_local", - "codex_local", - "gemini_local", - "opencode_local", - "pi_local", - "cursor", - "hermes_local", - "openclaw_gateway", -]); - function createValuesForAdapterType( adapterType: CreateConfigValues["adapterType"], ): CreateConfigValues { @@ -120,9 +111,7 @@ export function NewAgent() { useEffect(() => { const requested = presetAdapterType; if (!requested) return; - if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { - return; - } + if (!isValidAdapterType(requested)) return; setConfigValues((prev) => { if (prev.adapterType === requested) return prev; return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 88d0caab..fdfd6b90 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L // ── Status dot colors (raw hex for SVG) ───────────────────────────────── -const adapterLabels: Record = { - claude_local: "Claude", - codex_local: "Codex", - gemini_local: "Gemini", - opencode_local: "OpenCode", - cursor: "Cursor", - hermes_local: "Hermes", - openclaw_gateway: "OpenClaw Gateway", - process: "Process", - http: "HTTP", -}; +import { getAdapterLabel } from "../adapters/adapter-display-registry"; const statusDotColor: Record = { running: "#22d3ee", @@ -426,7 +416,7 @@ export function OrgChart() { {agent && ( - {adapterLabels[agent.adapterType] ?? agent.adapterType} + {getAdapterLabel(agent.adapterType)} )} {agent && agent.capabilities && ( diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index 5831ec82..a5fe8ac1 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -61,6 +61,10 @@ function readText(value: string | null | undefined) { return value ?? ""; } +function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) { + return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running"); +} + function formatJson(value: Record | null | undefined) { if (!value || Object.keys(value).length === 0) return ""; return JSON.stringify(value, null, 2); @@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() { variant="outline" size="sm" className="w-full sm:w-auto" - disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} + disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)} onClick={() => controlRuntimeServices.mutate("stop")} > Stop diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 55dc32f4..c1ca92e7 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { timeAgo } from "../lib/timeAgo"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -710,24 +711,13 @@ export function RoutineDetail() { }} disabled={runRoutine.isPending} /> - + aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"} + /> {automationLabel} diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx new file mode 100644 index 00000000..1d591dd7 --- /dev/null +++ b/ui/src/pages/Routines.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, RoutineListItem } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Routines, buildRoutineGroups } from "./Routines"; + +let currentSearch = ""; + +const navigateMock = vi.fn(); +const routinesListMock = vi.fn<(companyId: string) => Promise>(); +const issuesListMock = vi.fn<(companyId: string, filters?: Record) => Promise>(); +const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => ( +
{issues.map((issue) => issue.title).join(", ")}
+)); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => navigateMock, + useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }), + useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()], +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => ({ pushToast: vi.fn() }), +})); + +vi.mock("../api/routines", () => ({ + routinesApi: { + list: (companyId: string) => routinesListMock(companyId), + create: vi.fn(), + update: vi.fn(), + run: vi.fn(), + }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + list: (companyId: string, filters?: Record) => issuesListMock(companyId, filters), + update: vi.fn(), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: { + list: vi.fn(async () => [ + { + id: "agent-1", + companyId: "company-1", + name: "Agent One", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-one", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + { + id: "agent-2", + companyId: "company-1", + name: "Agent Two", + role: "engineer", + title: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: {}, + contextMode: "thin", + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + icon: "code", + metadata: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + urlKey: "agent-two", + pauseReason: null, + pausedAt: null, + permissions: null, + }, + ]), + }, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: { + list: vi.fn(async () => [ + { + id: "project-1", + companyId: "company-1", + urlKey: "project-alpha", + goalId: null, + goalIds: [], + goals: [], + name: "Project Alpha", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#22c55e", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + { + id: "project-2", + companyId: "company-1", + urlKey: "project-beta", + goalId: null, + goalIds: [], + goals: [], + name: "Project Beta", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#38bdf8", + pauseReason: null, + pausedAt: null, + archivedAt: null, + executionWorkspacePolicy: null, + codebase: null, + workspaces: [], + primaryWorkspace: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]), + }, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: { + getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })), + }, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: { + liveRunsForCompany: vi.fn(async () => []), + }, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props), +})); + +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ label: string }> }) => ( +
{items.map((item) => item.label).join(", ")}
+ ), +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: unknown }) =>
{children as never}
, + TabsContent: ({ children }: { children: unknown }) =>
{children as never}
, +})); + +vi.mock("../components/MarkdownEditor", () => ({ + MarkdownEditor: () =>
, +})); + +vi.mock("../components/InlineEntitySelector", () => ({ + InlineEntitySelector: () => , +})); + +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, + routineRunNeedsConfiguration: () => false, +})); + +vi.mock("../components/RoutineVariablesEditor", () => ({ + RoutineVariablesEditor: () => null, + RoutineVariablesHint: () => null, +})); + +vi.mock("../components/AgentIconPicker", () => ({ + AgentIcon: () => , +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createRoutine(overrides: Partial): RoutineListItem { + return { + id: "routine-1", + companyId: "company-1", + projectId: "project-1", + goalId: null, + parentIssueId: null, + title: "Routine title", + description: null, + assigneeAgentId: "agent-1", + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + triggers: [], + lastRun: null, + activeIssue: null, + ...overrides, + }; +} + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1000", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Routine execution issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1000, + originKind: "routine_execution", + originId: "routine-1", + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + lastActivityAt: new Date("2026-04-01T00:00:00.000Z"), + isUnreadForMe: false, + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +describe("Routines page", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + currentSearch = ""; + navigateMock.mockReset(); + routinesListMock.mockReset(); + issuesListMock.mockReset(); + issuesListRenderMock.mockClear(); + localStorage.clear(); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + }); + + it("groups routines by project using project names for the section labels", () => { + const groups = buildRoutineGroups( + [ + createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }), + createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }), + ], + "project", + new Map([ + ["project-1", { name: "Project Alpha" }], + ["project-2", { name: "Project Beta" }], + ]), + new Map([ + ["agent-1", { name: "Agent One" }], + ["agent-2", { name: "Agent Two" }], + ]), + ); + + expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]); + expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]); + expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]); + }); + + it("shows recent runs through the issues list scoped to routine execution issues", async () => { + currentSearch = "tab=runs"; + routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]); + issuesListMock.mockResolvedValue([ + createIssue({ id: "issue-1", title: "Routine execution A" }), + createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index fc856d72..85e32796 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -1,18 +1,25 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@/lib/router"; -import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; +import { useNavigate, useSearchParams } from "@/lib/router"; +import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { routinesApi } from "../api/routines"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { issuesApi } from "../api/issues"; +import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; +import { groupBy } from "../lib/groupBy"; +import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; +import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; +import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; @@ -33,6 +40,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -40,6 +48,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; @@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) { return enabled ? "active" : "paused"; } +type RoutinesTab = "routines" | "runs"; +type RoutineGroupBy = "none" | "project" | "assignee"; + +type RoutineViewState = { + groupBy: RoutineGroupBy; + collapsedGroups: string[]; +}; + +type RoutineGroup = { + key: string; + label: string | null; + items: RoutineListItem[]; +}; + +const defaultRoutineViewState: RoutineViewState = { + groupBy: "none", + collapsedGroups: [], +}; + +function getRoutineViewState(key: string): RoutineViewState { + try { + const raw = localStorage.getItem(key); + if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) }; + } catch { + // Ignore malformed local state and fall back to defaults. + } + return { ...defaultRoutineViewState }; +} + +function saveRoutineViewState(key: string, state: RoutineViewState) { + localStorage.setItem(key, JSON.stringify(state)); +} + +function formatRoutineRunStatus(value: string | null | undefined) { + if (!value) return null; + return value.replaceAll("_", " "); +} + +export function buildRoutineGroups( + routines: RoutineListItem[], + groupByValue: RoutineGroupBy, + projectById: Map, + agentById: Map, +): RoutineGroup[] { + if (groupByValue === "none") { + return [{ key: "__all", label: null, items: routines }]; + } + + if (groupByValue === "project") { + const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project"); + const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"), + items: groups[key]!, + })); + } + + const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned"); + return Object.keys(groups) + .sort((left, right) => { + const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent"); + const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent"); + return leftLabel.localeCompare(rightLabel); + }) + .map((key) => ({ + key, + label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"), + items: groups[key]!, + })); +} + +function buildRoutinesTabHref(tab: RoutinesTab) { + return tab === "runs" ? "/routines?tab=runs" : "/routines"; +} + +function RoutineListRow({ + routine, + projectById, + agentById, + runningRoutineId, + statusMutationRoutineId, + onNavigate, + onRunNow, + onToggleEnabled, + onToggleArchived, +}: { + routine: RoutineListItem; + projectById: Map; + agentById: Map; + runningRoutineId: string | null; + statusMutationRoutineId: string | null; + onNavigate: (routineId: string) => void; + onRunNow: (routine: RoutineListItem) => void; + onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; + onToggleArchived: (routine: RoutineListItem) => void; +}) { + const enabled = routine.status === "active"; + const isArchived = routine.status === "archived"; + const isStatusPending = statusMutationRoutineId === routine.id; + const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; + const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; + + return ( +
onNavigate(routine.id)} + > +
+
+ {routine.title} + {(isArchived || routine.status === "paused") ? ( + + {isArchived ? "archived" : "paused"} + + ) : null} +
+
+ + + {project?.name ?? "Unknown project"} + + + {agent?.icon ? : null} + {agent?.name ?? "Unknown agent"} + + + {formatLastRunTimestamp(routine.lastRun?.triggeredAt)} + {routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""} + +
+
+ +
event.stopPropagation()}> +
+ onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} + /> + + {isArchived ? "Archived" : enabled ? "On" : "Off"} + +
+ + + + + + + onNavigate(routine.id)}> + Edit + + onRunNow(routine)} + > + {runningRoutineId === routine.id ? "Running..." : "Run now"} + + + onToggleEnabled(routine, enabled)} + disabled={isStatusPending || isArchived} + > + {enabled ? "Pause" : "Enable"} + + onToggleArchived(routine)} + disabled={isStatusPending} + > + {routine.status === "archived" ? "Restore" : "Archive"} + + + +
+
+ ); +} + export function Routines() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { pushToast } = useToast(); const descriptionEditorRef = useRef(null); const titleInputRef = useRef(null); @@ -85,6 +286,7 @@ export function Routines() { const [runDialogRoutine, setRunDialogRoutine] = useState(null); const [composerOpen, setComposerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines"; const [draft, setDraft] = useState<{ title: string; description: string; @@ -104,11 +306,19 @@ export function Routines() { catchUpPolicy: "skip_missed", variables: [], }); + const routineViewStateKey = selectedCompanyId + ? `paperclip:routines-view:${selectedCompanyId}` + : "paperclip:routines-view"; + const [routineViewState, setRoutineViewState] = useState(() => getRoutineViewState(routineViewStateKey)); useEffect(() => { setBreadcrumbs([{ label: "Routines" }]); }, [setBreadcrumbs]); + useEffect(() => { + setRoutineViewState(getRoutineViewState(routineViewStateKey)); + }, [routineViewStateKey]); + const { data: routines, isLoading, error } = useQuery({ queryKey: queryKeys.routines.list(selectedCompanyId!), queryFn: () => routinesApi.list(selectedCompanyId!), @@ -129,6 +339,17 @@ export function Routines() { queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], + queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), + enabled: !!selectedCompanyId && activeTab === "runs", + }); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(selectedCompanyId!), + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: !!selectedCompanyId && activeTab === "runs", + refetchInterval: 5000, + }); useEffect(() => { autoResizeTextarea(titleInputRef.current); @@ -162,6 +383,13 @@ export function Routines() { navigate(`/routines/${routine.id}?tab=triggers`); }, }); + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] }); + }, + }); const updateRoutineStatus = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), @@ -249,10 +477,45 @@ export function Routines() { () => new Map((projects ?? []).map((project) => [project.id, project])), [projects], ); + const liveIssueIds = useMemo(() => { + const ids = new Set(); + for (const run of liveRuns ?? []) { + if (run.issueId) ids.add(run.issueId); + } + return ids; + }, [liveRuns]); + const routineGroups = useMemo( + () => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById), + [agentById, projectById, routineViewState.groupBy, routines], + ); + const recentRunsIssueLinkState = useMemo( + () => + createIssueDetailLocationState( + "Recent Runs", + buildRoutinesTabHref("runs"), + "issues", + ), + [], + ); const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; + function updateRoutineView(patch: Partial) { + setRoutineViewState((current) => { + const next = { ...current, ...patch }; + saveRoutineViewState(routineViewStateKey, next); + return next; + }); + } + + function handleTabChange(tab: string) { + const nextTab = tab === "runs" ? "runs" : "routines"; + startTransition(() => { + navigate(buildRoutinesTabHref(nextTab)); + }); + } + function handleRunNow(routine: RoutineListItem) { const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const needsConfiguration = routineRunNeedsConfiguration({ @@ -267,6 +530,20 @@ export function Routines() { runRoutine.mutate({ id: routine.id, data: {} }); } + function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { + updateRoutineStatus.mutate({ + id: routine.id, + status: nextRoutineStatus(routine.status, !enabled), + }); + } + + function handleToggleArchived(routine: RoutineListItem) { + updateRoutineStatus.mutate({ + id: routine.id, + status: routine.status === "archived" ? "active" : "archived", + }); + } + if (!selectedCompanyId) { return ; } @@ -293,6 +570,68 @@ export function Routines() {
+ + + +
+

+ {(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"} +

+ + + + + +
+ {([ + ["project", "Project"], + ["assignee", "Agent"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+
+
+ + updateIssue.mutate({ id, data })} + /> + +
+ { @@ -560,165 +899,64 @@ export function Routines() { ) : null} -
- {(routines ?? []).length === 0 ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {(routines ?? []).map((routine) => { - const enabled = routine.status === "active"; - const isArchived = routine.status === "archived"; - const isStatusPending = statusMutationRoutineId === routine.id; - return ( - navigate(`/routines/${routine.id}`)} - > - - - - - - - - ); - })} - -
NameProjectAgentLast runEnabled -
-
- - {routine.title} - - {(isArchived || routine.status === "paused") && ( -
- {isArchived ? "archived" : "paused"} -
- )} -
-
- {routine.projectId ? ( -
- - {projectById.get(routine.projectId)?.name ?? "Unknown"} -
- ) : ( - - )} -
- {routine.assigneeAgentId ? (() => { - const agent = agentById.get(routine.assigneeAgentId); - return agent ? ( -
- - {agent.name} -
- ) : ( - Unknown - ); - })() : ( - - )} -
-
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
- {routine.lastRun ? ( -
{routine.lastRun.status.replaceAll("_", " ")}
- ) : null} -
e.stopPropagation()}> -
- - - {isArchived ? "Archived" : enabled ? "On" : "Off"} - -
-
e.stopPropagation()}> - - - - - - navigate(`/routines/${routine.id}`)}> - Edit - - handleRunNow(routine)} - > - {runningRoutineId === routine.id ? "Running..." : "Run now"} - - - - updateRoutineStatus.mutate({ - id: routine.id, - status: enabled ? "paused" : "active", - }) - } - disabled={isStatusPending || isArchived} - > - {enabled ? "Pause" : "Enable"} - - - updateRoutineStatus.mutate({ - id: routine.id, - status: routine.status === "archived" ? "active" : "archived", - }) - } - disabled={isStatusPending} - > - {routine.status === "archived" ? "Restore" : "Archive"} - - - -
-
- )} -
+ {activeTab === "routines" ? ( +
+ {(routines ?? []).length === 0 ? ( +
+ +
+ ) : ( +
+ {routineGroups.map((group) => ( + { + updateRoutineView({ + collapsedGroups: open + ? routineViewState.collapsedGroups.filter((item) => item !== group.key) + : [...routineViewState.collapsedGroups, group.key], + }); + }} + > + {group.label ? ( +
+ + + + {group.label} + + + + {group.items.length} + +
+ ) : null} + + {group.items.map((routine) => ( + navigate(`/routines/${routineId}`)} + onRunNow={handleRunNow} + onToggleEnabled={handleToggleEnabled} + onToggleArchived={handleToggleArchived} + /> + ))} + +
+ ))} +
+ )} +
+ ) : null}