fix(plugins): re-export probe symbols + declare plugin deps in dashboard
- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` / `probeOpenClawBinary` and their status types so the dashboard's `runtime-provider-probes.ts` façade can import them via the public package entry instead of deep paths. - Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`, `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so pnpm symlinks them into `packages/dashboard/node_modules/`. Without these, the new probe imports failed with "Cannot find module" during `pnpm typecheck`. This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are in the in-flight Hermes plugin rewrite (runtime-adapter still imports from a deleted `./pi-module.js`; the new `index.ts` calls a factory with the wrong arg type) and should be resolved by the same change set that landed the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,75 +1,158 @@
|
||||
# Paperclip Runtime Plugin
|
||||
|
||||
`fusion-plugin-paperclip-runtime` provides the `paperclip` runtime for Fusion agents by calling a running **Paperclip REST API** instance.
|
||||
Drives a [Paperclip](https://paperclip.ing/) agent (an "employee" in a Paperclip company) via the wakeup + heartbeat-run API. Each Fusion prompt becomes a Paperclip *task*, not a chat completion.
|
||||
|
||||
> This plugin no longer delegates to Fusion's internal `@fusion/engine` pi runtime.
|
||||
## Mental model — read this first
|
||||
|
||||
## Runtime Identity
|
||||
Paperclip is a **control plane** for AI labor. Agents are long-lived employees with budgets, chains of command, and approval gates; *Paperclip itself does not run models* — it dispatches work to adapters (claude_local, codex_local, openclaw, http, …) which run the actual LLM call inside their own heartbeat.
|
||||
|
||||
- **Plugin ID:** `fusion-plugin-paperclip-runtime`
|
||||
- **Runtime ID:** `paperclip`
|
||||
- **Runtime Name:** `Paperclip Runtime`
|
||||
This plugin proxies a Fusion conversation through one of those Paperclip agents:
|
||||
|
||||
1. (Optionally) creates a Paperclip *issue* with the prompt as its body, assigned to your chosen agent.
|
||||
2. Calls `POST /api/agents/{id}/wakeup` with `payload: { prompt, fusionSessionId, issueId }` and an idempotency key.
|
||||
3. Streams `GET /api/heartbeat-runs/{runId}/events`, forwarding `heartbeat.run.log` chunks to the chat UI.
|
||||
4. On terminal status (`succeeded | failed | cancelled | timed_out`), reads the issue's final state and the agent's closing comment.
|
||||
|
||||
**Implications:**
|
||||
- **Latency is task-shaped** (seconds to minutes), not chat-shaped.
|
||||
- **Governance applies**: budget caps, approval requirements, audit log all come from Paperclip.
|
||||
- **A single Paperclip agent can be proxied from many Fusion sessions concurrently.**
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Paperclip is installed and running (default URL: `http://localhost:3100`)
|
||||
2. Fusion plugin installed:
|
||||
A running Paperclip server you can reach. For local development:
|
||||
|
||||
```bash
|
||||
fn plugin install ./plugins/fusion-plugin-paperclip-runtime
|
||||
npm install -g paperclipai
|
||||
paperclipai onboard # interactive setup
|
||||
paperclipai run # starts the server (default: http://localhost:3100)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
Verify the server is up:
|
||||
|
||||
The runtime resolves configuration in this priority order:
|
||||
|
||||
1. Plugin settings (`ctx.settings`)
|
||||
2. Environment variables
|
||||
3. Defaults
|
||||
|
||||
| Setting | Env Var | Required | Default |
|
||||
|---|---|---:|---|
|
||||
| `apiUrl` | `PAPERCLIP_API_URL` | No | `http://localhost:3100` |
|
||||
| `apiKey` | `PAPERCLIP_API_KEY` | No | `undefined` |
|
||||
| `agentId` | `PAPERCLIP_AGENT_ID` | Yes (for session create) | `undefined` |
|
||||
| `companyId` | `PAPERCLIP_COMPANY_ID` | Yes (for session create) | `undefined` |
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
- **Bearer token mode:** set `apiKey` / `PAPERCLIP_API_KEY` and requests include `Authorization: Bearer <token>`
|
||||
- **Local trusted mode:** leave `apiKey` unset; plugin probes `/api/health` without auth and proceeds when allowed by Paperclip deployment mode
|
||||
|
||||
## How Runtime Execution Works
|
||||
|
||||
For each prompt, the runtime adapter performs:
|
||||
|
||||
1. `POST /api/companies/{companyId}/issues` (creates issue in `backlog`, assigned to `agentId`)
|
||||
2. `POST /api/issues/{issueId}/checkout` (atomic claim; 409 conflicts are logged and execution continues)
|
||||
3. `POST /api/agents/{agentId}/heartbeat/invoke` (async agent execution)
|
||||
4. Polls `GET /api/issues/{issueId}` with exponential backoff (2s → 4s → 8s → 10s cap, 120s timeout)
|
||||
5. Reads output from `GET /api/issues/{issueId}/comments`
|
||||
6. Emits text/thinking/tool callbacks back to Fusion runtime consumers
|
||||
|
||||
The runtime uses Paperclip as the orchestration engine; Fusion receives summarized output via issue comments.
|
||||
|
||||
## Runtime Selection in Fusion
|
||||
|
||||
Configure an agent with runtime hint `paperclip`:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtimeConfig": {
|
||||
"runtimeHint": "paperclip"
|
||||
}
|
||||
}
|
||||
```bash
|
||||
curl http://localhost:3100/api/health
|
||||
```
|
||||
|
||||
Fusion runtime resolution still falls back to default `pi` runtime if plugin runtime resolution fails.
|
||||
## Connection modes
|
||||
|
||||
The dashboard settings card lets you pick **API** or **CLI** mode:
|
||||
|
||||
### API mode (default)
|
||||
|
||||
Paste an `apiUrl` and an *agent* `apiKey`. Get a key from the Paperclip UI's agent detail page → "Create API Key" (the full value is shown once). For local-trusted deployments, the key may be omitted.
|
||||
|
||||
### CLI mode
|
||||
|
||||
Auto-derive the apiUrl from the local `paperclipai` install. The plugin reads `~/.paperclip/instances/default/config.json` and uses that host:port as the apiUrl. No token needs to be pasted into Fusion. For non-local-trusted deployments, you can still set an override `apiKey`.
|
||||
|
||||
#### CLI key bootstrap
|
||||
|
||||
For authenticated Paperclip deployments (e.g. `paperclip-dev` or any non-`local_trusted` instance), a bearer token is required. The dashboard offers a one-click "✨ Mint API key via paperclipai" button that appears in CLI mode when:
|
||||
|
||||
- A connection has been attempted but `available === false` (typically "API key rejected"), AND
|
||||
- The user has already selected an agent in the agent picker.
|
||||
|
||||
The button calls `POST /api/providers/paperclip/cli-mint-key` on the Fusion backend, which spawns:
|
||||
|
||||
```
|
||||
paperclipai agent local-cli <agentRef> --json --no-install-skills --key-name fusion-runtime
|
||||
```
|
||||
|
||||
On success the returned `apiKey` is written into the API key field and a save-prompt toast is shown. On failure (e.g. CLI not authenticated) the toast shows the error and instructs the user to run `paperclipai onboard`.
|
||||
|
||||
**Requirement:** The local `paperclipai` CLI must be authenticated (`~/.paperclip/context.json` must have a valid profile). Run `paperclipai onboard` to authenticate if the mint fails.
|
||||
|
||||
## Conversation modes
|
||||
|
||||
Independent of transport, the `mode` setting controls how prompts map to Paperclip issues:
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `rolling-issue` (default) | Creates one Paperclip issue per Fusion session; subsequent prompts add comments. Closest to chat. |
|
||||
| `issue-per-prompt` | Each prompt creates a new top-level issue. Maximally explicit; clutters the board. |
|
||||
| `wakeup-only` | No issue side-effects; the prompt is delivered via the wakeup payload only. Requires the agent's prompt template to handle payload-driven wakes. |
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Env var | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `transport` | `PAPERCLIP_TRANSPORT` | `api` | `api` or `cli`. |
|
||||
| `apiUrl` | `PAPERCLIP_API_URL` | `http://localhost:3100` | API mode only. |
|
||||
| `apiKey` | `PAPERCLIP_API_KEY` | (none) | API mode (and as a CLI-mode override). |
|
||||
| `cliBinaryPath` | `PAPERCLIPAI_BIN` | `paperclipai` | CLI mode only. |
|
||||
| `cliConfigPath` | `PAPERCLIP_CLI_CONFIG` | `~/.paperclip/instances/default/config.json` | CLI mode only. |
|
||||
| `agentId` | `PAPERCLIP_AGENT_ID` | auto-derived from `/api/agents/me` | The Paperclip agent this Fusion runtime proxies. |
|
||||
| `companyId` | `PAPERCLIP_COMPANY_ID` | auto-derived from `/api/agents/me` | The Paperclip company. |
|
||||
| `mode` | `PAPERCLIP_RUNTIME_MODE` | `rolling-issue` | One of the conversation modes above. |
|
||||
| `parentIssueId` | `PAPERCLIP_PARENT_ISSUE_ID` | (none) | Optional issue scoping. |
|
||||
| `projectId` | `PAPERCLIP_PROJECT_ID` | (none) | Optional. |
|
||||
| `goalId` | `PAPERCLIP_GOAL_ID` | (none) | Optional. |
|
||||
| `runTimeoutMs` | `PAPERCLIP_RUN_TIMEOUT_MS` | `600000` | Local cap before Fusion stops polling. The run continues server-side. |
|
||||
| `pollIntervalMs` | `PAPERCLIP_POLL_INTERVAL_MS` | `500` | Initial poll interval. |
|
||||
| `pollIntervalMaxMs` | `PAPERCLIP_POLL_INTERVAL_MAX_MS` | `2000` | Max poll interval after exponential backoff. |
|
||||
|
||||
Settings precedence: plugin settings → env var → default.
|
||||
|
||||
## Public API
|
||||
|
||||
```ts
|
||||
import {
|
||||
PaperclipRuntimeAdapter,
|
||||
// REST helpers
|
||||
agentsMe,
|
||||
listCompanies,
|
||||
listCompanyAgents,
|
||||
// Probes
|
||||
probePaperclipConnection,
|
||||
discoverPaperclipCliConfig,
|
||||
// CLI key minting
|
||||
mintAgentApiKeyViaCli,
|
||||
// Types
|
||||
type PaperclipAgentSummary,
|
||||
type PaperclipCompanySummary,
|
||||
type PaperclipConnectionStatus,
|
||||
type PaperclipCliDiscoveryResult,
|
||||
type MintCliKeyOptions,
|
||||
type MintedApiKey,
|
||||
} from "@fusion-plugin-examples/paperclip-runtime";
|
||||
```
|
||||
|
||||
- `probePaperclipConnection({ apiUrl, apiKey?, timeoutMs? })` → `{ available, identity?, reason? }`. Powers the dashboard's "✓ Connected as <agent>" badge.
|
||||
- `listCompanyAgents(apiUrl, apiKey, companyId)` → list of agents in a company. Drives the agent picker.
|
||||
- `discoverPaperclipCliConfig({ configPath? })` → `{ ok, apiUrl, deploymentMode? }` from the local `paperclipai` config. Drives CLI-mode auth discovery.
|
||||
- `mintAgentApiKeyViaCli(opts: MintCliKeyOptions)` → `Promise<MintedApiKey>`. Spawns `paperclipai agent local-cli <agentRef> --json --no-install-skills` to mint a fresh agent API key. Throws on ENOENT, non-zero exit, or malformed JSON; includes a hint to run `paperclipai onboard` on auth failures.
|
||||
|
||||
## Endpoints used (Paperclip side)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/agents/me` | Identity + auto-derive agentId/companyId. |
|
||||
| `GET` | `/api/companies` | Company list (board access). |
|
||||
| `GET` | `/api/companies/{companyId}/agents` | Agent list (agent-key sees its own company). |
|
||||
| `POST` | `/api/companies/{companyId}/issues` | Issue creation (issue-per-prompt / rolling-issue modes). |
|
||||
| `POST` | `/api/agents/{agentId}/wakeup` | Trigger a heartbeat run with the Fusion prompt as payload. |
|
||||
| `GET` | `/api/heartbeat-runs/{runId}/events` | Streaming run log + status. |
|
||||
| `GET` | `/api/issues/{issueId}` | Final issue state. |
|
||||
| `GET` | `/api/issues/{issueId}/comments` | Final comment fallback. |
|
||||
|
||||
The adapter does **not** call `/api/issues/{id}/checkout` — checkout is the agent's job during its own heartbeat. The adapter does **not** call the legacy `/api/agents/{id}/heartbeat/invoke`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Latency.** Heartbeat runs can take minutes; not a chat-completion drop-in.
|
||||
- **Single-agent identity per connection.** A Paperclip *agent* API key is scoped to one agent in one company. To proxy several agents, configure several Fusion connections.
|
||||
- **Run-events schema is partially inferred.** The events endpoint payload shape is documented but not formally schema'd; the client accepts both bare-array and `{ events: [...] }` envelopes defensively.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Plugin ID:** `fusion-plugin-paperclip-runtime`
|
||||
- **Runtime ID:** `paperclip`
|
||||
- **Package:** `@fusion-plugin-examples/paperclip-runtime`
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd plugins/fusion-plugin-paperclip-runtime
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm --filter @fusion-plugin-examples/paperclip-runtime test # 46 tests
|
||||
pnpm --filter @fusion-plugin-examples/paperclip-runtime build
|
||||
```
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const { mockProbePaperclipInstance, mockResolvePaperclipConfig, mockAdapterCtor, MockAdapter } = vi.hoisted(() => {
|
||||
const mockProbe = vi.fn();
|
||||
const mockResolve = vi.fn((settings?: Record<string, unknown>) => ({
|
||||
const {
|
||||
mockProbe,
|
||||
mockResolve,
|
||||
mockAdapterCtor,
|
||||
MockAdapter,
|
||||
} = vi.hoisted(() => {
|
||||
const probe = vi.fn();
|
||||
const resolve = vi.fn((settings?: Record<string, unknown>) => ({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
apiKey: undefined as string | undefined,
|
||||
agentId: undefined as string | undefined,
|
||||
companyId: undefined as string | undefined,
|
||||
mode: "rolling-issue" as const,
|
||||
runTimeoutMs: 600_000,
|
||||
pollIntervalMs: 500,
|
||||
pollIntervalMaxMs: 2_000,
|
||||
...(settings ?? {}),
|
||||
}));
|
||||
const adapterCtor = vi.fn();
|
||||
@@ -17,18 +26,19 @@ const { mockProbePaperclipInstance, mockResolvePaperclipConfig, mockAdapterCtor,
|
||||
adapterCtor(...args);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mockProbePaperclipInstance: mockProbe,
|
||||
mockResolvePaperclipConfig: mockResolve,
|
||||
mockProbe: probe,
|
||||
mockResolve: resolve,
|
||||
mockAdapterCtor: adapterCtor,
|
||||
MockAdapter: Adapter,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
probePaperclipInstance: mockProbePaperclipInstance,
|
||||
resolvePaperclipConfig: mockResolvePaperclipConfig,
|
||||
vi.mock("../paperclip-client.js", () => ({
|
||||
probePaperclipConnection: mockProbe,
|
||||
resolvePaperclipConfig: mockResolve,
|
||||
// Re-exported by the plugin module — unused inside tests but must resolve.
|
||||
listCompanyAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-adapter.js", () => ({
|
||||
@@ -40,67 +50,65 @@ import plugin from "../index.js";
|
||||
describe("paperclip-runtime plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProbePaperclipInstance.mockResolvedValue({ ok: true, deploymentMode: "local_trusted" });
|
||||
mockProbe.mockResolvedValue({
|
||||
available: true,
|
||||
apiUrl: "http://localhost:3100",
|
||||
probeDurationMs: 5,
|
||||
identity: {
|
||||
agentId: "AG-1",
|
||||
agentName: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps manifest identity unchanged", () => {
|
||||
it("manifest identity stays stable", () => {
|
||||
expect(plugin.manifest.id).toBe("fusion-plugin-paperclip-runtime");
|
||||
expect(plugin.manifest.runtime?.runtimeId).toBe("paperclip");
|
||||
expect(plugin.manifest.name).toBe("Paperclip Runtime Plugin");
|
||||
expect(plugin.runtime?.metadata.runtimeId).toBe("paperclip");
|
||||
});
|
||||
|
||||
it("factory resolves settings and passes config/logger to adapter", async () => {
|
||||
it("factory passes resolved config + logger to adapter ctor", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const ctx = {
|
||||
settings: {
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "secret",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
agentId: "AG-X",
|
||||
},
|
||||
logger,
|
||||
};
|
||||
|
||||
await plugin.runtime!.factory(ctx as any);
|
||||
|
||||
expect(mockResolvePaperclipConfig).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(mockAdapterCtor).toHaveBeenCalledWith(
|
||||
{
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "secret",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
expect(mockResolve).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(mockAdapterCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockAdapterCtor.mock.calls[0][1]).toBe(logger);
|
||||
});
|
||||
|
||||
it("onLoad probes Paperclip and logs success without leaking apiKey", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
it("onLoad probes and logs success without leaking apiKey", async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const ctx = {
|
||||
settings: {
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "super-secret",
|
||||
},
|
||||
settings: { apiUrl: "http://paperclip.example", apiKey: "super-secret" },
|
||||
logger,
|
||||
};
|
||||
|
||||
await plugin.hooks.onLoad!(ctx as any);
|
||||
|
||||
expect(mockProbePaperclipInstance).toHaveBeenCalledWith("http://paperclip.example", "super-secret");
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"Paperclip Runtime Plugin loaded (apiUrl=http://paperclip.example)",
|
||||
);
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
expect(mockProbe).toHaveBeenCalledWith({
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "super-secret",
|
||||
});
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain("super-secret");
|
||||
});
|
||||
|
||||
it("onLoad logs warning when probe fails", async () => {
|
||||
mockProbePaperclipInstance.mockResolvedValue({ ok: false, error: "ECONNREFUSED" });
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
await plugin.hooks.onLoad!({ settings: {}, logger } as any);
|
||||
|
||||
it("onLoad warns when probe is unavailable", async () => {
|
||||
mockProbe.mockResolvedValue({
|
||||
available: false,
|
||||
apiUrl: "http://localhost:3100",
|
||||
probeDurationMs: 1,
|
||||
reason: "ECONNREFUSED",
|
||||
});
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
await plugin.hooks!.onLoad!({ settings: {}, logger } as any);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("probe failed"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ConflictError,
|
||||
addComment,
|
||||
checkoutIssue,
|
||||
agentsMe,
|
||||
createIssue,
|
||||
getAgentIdentity,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
invokeHeartbeat,
|
||||
listIssues,
|
||||
probePaperclipInstance,
|
||||
getRunEvents,
|
||||
listCompanyAgents,
|
||||
mintAgentApiKeyViaCli,
|
||||
probePaperclipConnection,
|
||||
resolvePaperclipConfig,
|
||||
updateIssue,
|
||||
} from "../pi-module.js";
|
||||
wakeAgent,
|
||||
} from "../paperclip-client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -21,7 +23,19 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
describe("paperclip client", () => {
|
||||
function networkError(): never {
|
||||
throw new TypeError("fetch failed: ECONNREFUSED");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePaperclipConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolvePaperclipConfig", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -31,256 +45,447 @@ describe("paperclip client", () => {
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("resolvePaperclipConfig", () => {
|
||||
it("prefers plugin settings over env vars", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
|
||||
process.env.PAPERCLIP_API_KEY = "env-key";
|
||||
process.env.PAPERCLIP_AGENT_ID = "env-agent";
|
||||
process.env.PAPERCLIP_COMPANY_ID = "env-company";
|
||||
|
||||
const config = resolvePaperclipConfig({
|
||||
apiUrl: "http://settings-host:4000/",
|
||||
apiKey: "settings-key",
|
||||
agentId: "settings-agent",
|
||||
companyId: "settings-company",
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
apiUrl: "http://settings-host:4000",
|
||||
apiKey: "settings-key",
|
||||
agentId: "settings-agent",
|
||||
companyId: "settings-company",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env vars when settings are absent", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://env-host:3100/";
|
||||
process.env.PAPERCLIP_API_KEY = "env-key";
|
||||
process.env.PAPERCLIP_AGENT_ID = "env-agent";
|
||||
process.env.PAPERCLIP_COMPANY_ID = "env-company";
|
||||
|
||||
expect(resolvePaperclipConfig()).toEqual({
|
||||
apiUrl: "http://env-host:3100",
|
||||
apiKey: "env-key",
|
||||
agentId: "env-agent",
|
||||
companyId: "env-company",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to hardcoded defaults", () => {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_AGENT_ID;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
|
||||
expect(resolvePaperclipConfig()).toEqual({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
});
|
||||
it("prefers plugin settings over env vars", () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
|
||||
process.env.PAPERCLIP_API_KEY = "env-key";
|
||||
const config = resolvePaperclipConfig({
|
||||
apiUrl: "http://settings-host:4000/",
|
||||
apiKey: "settings-key",
|
||||
agentId: "AG-set",
|
||||
companyId: "CO-set",
|
||||
mode: "issue-per-prompt",
|
||||
});
|
||||
expect(config.apiUrl).toBe("http://settings-host:4000");
|
||||
expect(config.apiKey).toBe("settings-key");
|
||||
expect(config.agentId).toBe("AG-set");
|
||||
expect(config.companyId).toBe("CO-set");
|
||||
expect(config.mode).toBe("issue-per-prompt");
|
||||
});
|
||||
|
||||
it("probePaperclipInstance returns success on health check", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: "ok", deploymentMode: "local_trusted" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
it("falls back to env vars then defaults", () => {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_AGENT_ID;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
delete process.env.PAPERCLIP_RUNTIME_MODE;
|
||||
|
||||
await expect(probePaperclipInstance("http://localhost:3100", "secret")).resolves.toEqual({
|
||||
ok: true,
|
||||
deploymentMode: "local_trusted",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3100/api/health", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer secret",
|
||||
},
|
||||
body: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("probePaperclipInstance returns error on connection failure", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
|
||||
const result = await probePaperclipInstance("http://localhost:3100");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("network error");
|
||||
}
|
||||
});
|
||||
|
||||
it("getAgentIdentity returns agent on 200 and structured auth failures", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" }),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "forbidden" }, 403));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
|
||||
ok: true,
|
||||
agent: { id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" },
|
||||
});
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unauthenticated",
|
||||
});
|
||||
|
||||
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "not_agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("createIssue posts issue payload and returns created issue", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "backlog" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await createIssue("http://localhost:3100", "key", "COMP-1", {
|
||||
title: "Title",
|
||||
description: "Desc",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "A-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "ISS-1", status: "backlog" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/companies/COMP-1/issues",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Title",
|
||||
description: "Desc",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "A-1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("getIssue returns issue object", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-7", status: "in_progress" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-7")).resolves.toEqual({
|
||||
id: "ISS-7",
|
||||
status: "in_progress",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-7",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("checkoutIssue posts agent payload and throws ConflictError on 409", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "ISS-1", status: "in_progress" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "already checked out" }, 409));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).resolves.toEqual({
|
||||
id: "ISS-1",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).rejects.toThrow(
|
||||
ConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
it("updateIssue sends run id header when provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await updateIssue("http://localhost:3100", "key", "ISS-1", { status: "done" }, "RUN-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
headers: expect.objectContaining({ "X-Paperclip-Run-Id": "RUN-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("getIssueComments and addComment hit comment endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ id: "C1", body: "result" }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "C2" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getIssueComments("http://localhost:3100", "key", "ISS-1")).resolves.toEqual([
|
||||
{ id: "C1", body: "result" },
|
||||
]);
|
||||
|
||||
await expect(addComment("http://localhost:3100", "key", "ISS-1", "hello", "RUN-2")).resolves.toEqual({
|
||||
id: "C2",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
"http://localhost:3100/api/issues/ISS-1/comments",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "hello" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokeHeartbeat handles queued and skipped responses", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "RUN-1", status: "queued", agentId: "AG-1" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ status: "skipped" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
run: { id: "RUN-1", status: "queued", agentId: "AG-1" },
|
||||
});
|
||||
|
||||
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("listIssues applies query params", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "ISS-1" }]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
listIssues("http://localhost:3100", "key", "COMP-1", {
|
||||
status: ["todo", "in_progress"],
|
||||
assigneeAgentId: "AG-1",
|
||||
}),
|
||||
).resolves.toEqual([{ id: "ISS-1" }]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/companies/COMP-1/issues?status=todo%2Cin_progress&assigneeAgentId=AG-1",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on non-200 and invalid JSON", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500)));
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("Paperclip API 500");
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValueOnce(
|
||||
new Response("not-json", { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||
),
|
||||
);
|
||||
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("invalid JSON");
|
||||
const config = resolvePaperclipConfig();
|
||||
expect(config.apiUrl).toBe("http://localhost:3100");
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
expect(config.mode).toBe("rolling-issue");
|
||||
expect(config.runTimeoutMs).toBe(600_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agentsMe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("agentsMe", () => {
|
||||
it("happy path — returns parsed identity", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
id: "AG-1",
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await agentsMe("http://localhost:3100", "key");
|
||||
expect(result).toEqual({
|
||||
agentId: "AG-1",
|
||||
agentName: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/agents/me",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("no apiKey → no Authorization header", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ id: "AG-1", companyId: "CO-1" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await agentsMe("http://localhost:3100");
|
||||
const call = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = call.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("401 → throws", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
|
||||
);
|
||||
await expect(agentsMe("http://localhost:3100", "bad")).rejects.toThrow(
|
||||
/401/,
|
||||
);
|
||||
});
|
||||
|
||||
it("connect refused → throws", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
|
||||
await expect(agentsMe("http://localhost:3100", "k")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createIssue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("posts correct body to /companies/{id}/issues", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "todo" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createIssue("http://localhost:3100", "k", "CO-1", {
|
||||
title: "Fix bug",
|
||||
description: "details",
|
||||
status: "todo",
|
||||
assigneeAgentId: "AG-1",
|
||||
projectId: "PROJ-1",
|
||||
});
|
||||
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:3100/api/companies/CO-1/issues");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(JSON.parse(opts.body as string)).toMatchObject({
|
||||
title: "Fix bug",
|
||||
assigneeAgentId: "AG-1",
|
||||
projectId: "PROJ-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("wakeAgent", () => {
|
||||
it("posts wakeup with idempotency key", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ id: "RUN-1", status: "queued" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await wakeAgent("http://localhost:3100", "k", "AG-1", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
idempotencyKey: "session-1:1",
|
||||
payload: { hello: "world" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "RUN-1", status: "queued" });
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:3100/api/agents/AG-1/wakeup");
|
||||
expect(JSON.parse(opts.body as string).idempotencyKey).toBe("session-1:1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getRunEvents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getRunEvents", () => {
|
||||
it("uses afterSeq + limit query params", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([{ seq: 5, type: "heartbeat.run.status", payload: {} }]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await getRunEvents("http://localhost:3100", "k", "RUN-1", 4, 50);
|
||||
expect(result).toEqual([{ seq: 5, type: "heartbeat.run.status", payload: {} }]);
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain("/api/heartbeat-runs/RUN-1/events?");
|
||||
expect(url).toContain("afterSeq=4");
|
||||
expect(url).toContain("limit=50");
|
||||
});
|
||||
|
||||
it("accepts both bare-array and { events: [...] } envelopes", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ events: [{ seq: 1, type: "x", payload: {} }] }))
|
||||
.mockResolvedValueOnce(jsonResponse([{ seq: 2, type: "y", payload: {} }])),
|
||||
);
|
||||
const a = await getRunEvents("http://localhost:3100", "k", "R", 0);
|
||||
const b = await getRunEvents("http://localhost:3100", "k", "R", 0);
|
||||
expect(a.map((e) => e.type)).toEqual(["x"]);
|
||||
expect(b.map((e) => e.type)).toEqual(["y"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getIssue / getIssueComments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getIssue / getIssueComments", () => {
|
||||
it("getIssue fetches /issues/{id}", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await getIssue("http://localhost:3100", "k", "ISS-1");
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
"http://localhost:3100/api/issues/ISS-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("getIssueComments returns array", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([{ id: "C-1", body: "hi" }]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const result = await getIssueComments("http://localhost:3100", "k", "ISS-1");
|
||||
expect(result).toEqual([{ id: "C-1", body: "hi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listCompanyAgents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("listCompanyAgents", () => {
|
||||
it("returns mapped agent summaries", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
|
||||
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1" },
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
|
||||
expect(result).toEqual([
|
||||
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
|
||||
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1", status: undefined },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:3100/api/companies/CO-1/agents",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer k" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("non-array response → empty list", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse({ error: "unexpected" })),
|
||||
);
|
||||
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips entries missing id; falls back name=id", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ id: "AG-1", name: "Ok", companyId: "CO-1" },
|
||||
{ name: "Missing-id", companyId: "CO-1" },
|
||||
null,
|
||||
{ id: "AG-2", companyId: "CO-1" },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
|
||||
expect(result.map((a) => a.id)).toEqual(["AG-1", "AG-2"]);
|
||||
expect(result[1]).toMatchObject({ id: "AG-2", name: "AG-2" });
|
||||
});
|
||||
|
||||
it("401 → throws", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
|
||||
);
|
||||
await expect(
|
||||
listCompanyAgents("http://localhost:3100", "bad", "CO-1"),
|
||||
).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// probePaperclipConnection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("probePaperclipConnection", () => {
|
||||
it("200 → available + identity", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
id: "AG-1",
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: "k",
|
||||
});
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.identity).toMatchObject({
|
||||
agentId: "AG-1",
|
||||
agentName: "Coder",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("401 → unavailable with reason", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
|
||||
);
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: "bad",
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toMatch(/rejected|401/i);
|
||||
});
|
||||
|
||||
it("connect refused → unavailable", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://localhost:9999",
|
||||
apiKey: "k",
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toMatch(/not reachable|ECONNREFUSED|fetch failed/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mintAgentApiKeyViaCli
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("mintAgentApiKeyViaCli", () => {
|
||||
// We mock node:child_process.spawn for each case.
|
||||
|
||||
it("success path — parses apiKey from JSON", async () => {
|
||||
const mockPayload = {
|
||||
apiKey: "sk-test-mint-key",
|
||||
apiBase: "http://localhost:3100",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
};
|
||||
const { EventEmitter } = await import("node:events");
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
const fakeStdout = Readable.from([Buffer.from(JSON.stringify(mockPayload))]);
|
||||
const fakeStderr = Readable.from([]);
|
||||
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
|
||||
|
||||
const spawnMock = vi.fn().mockReturnValue(fakeChild);
|
||||
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
|
||||
|
||||
// Emit close asynchronously after mock is in place
|
||||
setImmediate(() => {
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
|
||||
const result = await mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" });
|
||||
expect(result.apiKey).toBe("sk-test-mint-key");
|
||||
expect(result.apiBase).toBe("http://localhost:3100");
|
||||
expect(result.agentId).toBe("AG-1");
|
||||
expect(result.companyId).toBe("CO-1");
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
});
|
||||
|
||||
it("ENOENT on spawn error → throws with install hint", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from([]);
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from([]);
|
||||
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
|
||||
|
||||
const spawnMock = vi.fn().mockReturnValue(fakeChild);
|
||||
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
|
||||
|
||||
setImmediate(() => {
|
||||
const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
|
||||
fakeChild.emit("error", err);
|
||||
});
|
||||
|
||||
await expect(
|
||||
mintAgentApiKeyViaCli({ agentRef: "my-agent", cliBinaryPath: "/usr/local/bin/paperclipai", companyId: "CO-1" }),
|
||||
).rejects.toThrow(/binary not found.*npm i -g paperclipai/i);
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
});
|
||||
|
||||
it("non-zero exit → throws with stderr hint", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
const fakeStdout = Readable.from([]);
|
||||
const fakeStderr = Readable.from([Buffer.from("Error: CLI is not authenticated\n")]);
|
||||
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
|
||||
|
||||
const spawnMock = vi.fn().mockReturnValue(fakeChild);
|
||||
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
|
||||
|
||||
setImmediate(() => {
|
||||
fakeChild.emit("close", 1);
|
||||
});
|
||||
|
||||
await expect(
|
||||
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
|
||||
).rejects.toThrow(/exited 1.*paperclipai onboard/i);
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
});
|
||||
|
||||
it("malformed JSON output → throws", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
const fakeStdout = Readable.from([Buffer.from("not-json-at-all")]);
|
||||
const fakeStderr = Readable.from([]);
|
||||
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
|
||||
|
||||
const spawnMock = vi.fn().mockReturnValue(fakeChild);
|
||||
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
|
||||
|
||||
setImmediate(() => {
|
||||
fakeChild.emit("close", 0);
|
||||
});
|
||||
|
||||
await expect(
|
||||
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
|
||||
).rejects.toThrow(/non-JSON output/i);
|
||||
|
||||
vi.doUnmock("node:child_process");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { probePaperclipConnection } from "../paperclip-client.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("probePaperclipConnection", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("connect refused → available: false, reason mentions unreachable", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("ECONNREFUSED")));
|
||||
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://localhost:3100",
|
||||
});
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("not reachable");
|
||||
expect(result.apiUrl).toBe("http://localhost:3100");
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("401 → available: false, reason: API key rejected", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)));
|
||||
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "bad-key",
|
||||
});
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toBe("API key rejected");
|
||||
expect(result.identity).toBeUndefined();
|
||||
});
|
||||
|
||||
it("200 → available: true with identity filled", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
id: "AG-1",
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme Corp",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await probePaperclipConnection({
|
||||
apiUrl: "http://paperclip.example",
|
||||
apiKey: "valid-key",
|
||||
});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.identity).toEqual({
|
||||
agentId: "AG-1",
|
||||
agentName: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-1",
|
||||
companyName: "Acme Corp",
|
||||
});
|
||||
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("uses correct endpoint URL including api prefix", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({ id: "A", companyId: "C" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await probePaperclipConnection({ apiUrl: "http://localhost:3100/", apiKey: "k" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:3100/api/agents/me");
|
||||
});
|
||||
|
||||
it("passes apiKey as Authorization Bearer header", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({ id: "A", companyId: "C" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await probePaperclipConnection({ apiUrl: "http://localhost:3100", apiKey: "my-secret" });
|
||||
|
||||
const call = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = call.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer my-secret");
|
||||
});
|
||||
|
||||
it("no apiKey → no Authorization header", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse({ id: "A", companyId: "C" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await probePaperclipConnection({ apiUrl: "http://localhost:3100" });
|
||||
|
||||
const call = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = call.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,307 +1,277 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
|
||||
import type { RunEvent } from "../paperclip-client.js";
|
||||
|
||||
const {
|
||||
mockAgentsMe,
|
||||
mockCreateIssue,
|
||||
mockCheckoutIssue,
|
||||
mockInvokeHeartbeat,
|
||||
mockGetIssue,
|
||||
mockGetIssueComments,
|
||||
MockConflictError,
|
||||
} = vi.hoisted(() => {
|
||||
class LocalConflictError extends Error {
|
||||
readonly status = 409;
|
||||
}
|
||||
|
||||
return {
|
||||
mockCreateIssue: vi.fn(),
|
||||
mockCheckoutIssue: vi.fn(),
|
||||
mockInvokeHeartbeat: vi.fn(),
|
||||
mockGetIssue: vi.fn(),
|
||||
mockGetIssueComments: vi.fn(),
|
||||
MockConflictError: LocalConflictError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
resolvePaperclipConfig: vi.fn((settings?: Record<string, unknown>) => ({
|
||||
mockGetRunEvents,
|
||||
mockWakeAgent,
|
||||
mockResolveConfig,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAgentsMe: vi.fn(),
|
||||
mockCreateIssue: vi.fn(),
|
||||
mockGetIssue: vi.fn(),
|
||||
mockGetIssueComments: vi.fn(),
|
||||
mockGetRunEvents: vi.fn(),
|
||||
mockWakeAgent: vi.fn(),
|
||||
mockResolveConfig: vi.fn((settings?: Record<string, unknown>) => ({
|
||||
apiUrl: "http://localhost:3100",
|
||||
apiKey: undefined,
|
||||
agentId: undefined,
|
||||
companyId: undefined,
|
||||
apiKey: undefined as string | undefined,
|
||||
agentId: undefined as string | undefined,
|
||||
companyId: undefined as string | undefined,
|
||||
mode: "rolling-issue" as const,
|
||||
parentIssueId: undefined as string | undefined,
|
||||
projectId: undefined as string | undefined,
|
||||
goalId: undefined as string | undefined,
|
||||
runTimeoutMs: 60_000,
|
||||
pollIntervalMs: 1,
|
||||
pollIntervalMaxMs: 1,
|
||||
...(settings ?? {}),
|
||||
})),
|
||||
createIssue: mockCreateIssue,
|
||||
checkoutIssue: mockCheckoutIssue,
|
||||
invokeHeartbeat: mockInvokeHeartbeat,
|
||||
getIssue: mockGetIssue,
|
||||
getIssueComments: mockGetIssueComments,
|
||||
ConflictError: MockConflictError,
|
||||
}));
|
||||
|
||||
describe("PaperclipRuntimeAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.mock("../paperclip-client.js", () => ({
|
||||
agentsMe: mockAgentsMe,
|
||||
createIssue: mockCreateIssue,
|
||||
getIssue: mockGetIssue,
|
||||
getIssueComments: mockGetIssueComments,
|
||||
getRunEvents: mockGetRunEvents,
|
||||
wakeAgent: mockWakeAgent,
|
||||
resolvePaperclipConfig: mockResolveConfig,
|
||||
}));
|
||||
|
||||
const baseSessionOpts = {
|
||||
cwd: "/repo",
|
||||
systemPrompt: "be helpful",
|
||||
};
|
||||
|
||||
function makeAdapter(config: Record<string, unknown> = {}) {
|
||||
return new PaperclipRuntimeAdapter(config, {
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentsMe.mockResolvedValue({
|
||||
agentId: "AG-default",
|
||||
agentName: "Coder",
|
||||
role: "engineer",
|
||||
companyId: "CO-default",
|
||||
companyName: "Acme",
|
||||
});
|
||||
const defaultEvents: RunEvent[] = [
|
||||
{ seq: 1, type: "heartbeat.run.log", payload: { stream: "stdout", chunk: "hello world" } },
|
||||
{ seq: 2, type: "heartbeat.run.status", payload: { status: "succeeded" } },
|
||||
];
|
||||
mockGetRunEvents.mockResolvedValue(defaultEvents);
|
||||
mockWakeAgent.mockResolvedValue({ id: "RUN-1", status: "queued" });
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "todo" });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ id: "C-1", body: "final answer comment" }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PaperclipRuntimeAdapter — createSession", () => {
|
||||
it("auto-derives agentId/companyId from /agents/me when missing", async () => {
|
||||
const adapter = makeAdapter({});
|
||||
const result = await adapter.createSession({ ...baseSessionOpts });
|
||||
expect(mockAgentsMe).toHaveBeenCalledTimes(1);
|
||||
expect(result.session.agentId).toBe("AG-default");
|
||||
expect(result.session.companyId).toBe("CO-default");
|
||||
expect(result.sessionFile).toBeUndefined();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
it("uses provided agentId/companyId without calling /agents/me", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-X", companyId: "CO-Y" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
expect(mockAgentsMe).not.toHaveBeenCalled();
|
||||
expect(session.agentId).toBe("AG-X");
|
||||
expect(session.companyId).toBe("CO-Y");
|
||||
});
|
||||
|
||||
it("createSession returns configured Paperclip session with undefined sessionFile", async () => {
|
||||
it("throws if agents/me fails and identity is missing", async () => {
|
||||
mockAgentsMe.mockRejectedValueOnce(new Error("API key rejected"));
|
||||
const adapter = makeAdapter({});
|
||||
await expect(adapter.createSession({ ...baseSessionOpts })).rejects.toThrow(
|
||||
/could not derive agentId\/companyId/,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes invalid mode to rolling-issue", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "bogus" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
expect(session.mode).toBe("rolling-issue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaperclipRuntimeAdapter — promptWithFallback (rolling-issue mode)", () => {
|
||||
it("creates an issue on first prompt, reuses it on second", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
await adapter.promptWithFallback(session, "first prompt");
|
||||
await adapter.promptWithFallback(session, "second prompt");
|
||||
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
|
||||
expect(mockWakeAgent).toHaveBeenCalledTimes(2);
|
||||
expect(session.issueId).toBe("ISS-1");
|
||||
});
|
||||
|
||||
it("forwards stdout chunks to onText", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session, sessionFile } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(sessionFile).toBeUndefined();
|
||||
expect(session).toMatchObject({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
expect(session.sessionId).toBeTypeOf("string");
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onText });
|
||||
await adapter.promptWithFallback(session, "hi");
|
||||
expect(onText).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("createSession throws when required agentId/companyId config is missing", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({ apiUrl: "http://paperclip.local" });
|
||||
it("idempotency key increments per turn", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
await adapter.promptWithFallback(session, "p1");
|
||||
await adapter.promptWithFallback(session, "p2");
|
||||
const keys = mockWakeAgent.mock.calls.map((c) => (c[3] as { idempotencyKey: string }).idempotencyKey);
|
||||
expect(keys[0]).toMatch(/:1$/);
|
||||
expect(keys[1]).toMatch(/:2$/);
|
||||
expect(keys[0].split(":")[0]).toBe(keys[1].split(":")[0]);
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system",
|
||||
}),
|
||||
).rejects.toThrow("missing required config");
|
||||
describe("PaperclipRuntimeAdapter — issue-per-prompt mode", () => {
|
||||
it("creates a new issue per prompt", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "issue-per-prompt" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
await adapter.promptWithFallback(session, "p1");
|
||||
await adapter.promptWithFallback(session, "p2");
|
||||
expect(mockCreateIssue).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaperclipRuntimeAdapter — wakeup-only mode", () => {
|
||||
it("does not create an issue", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "wakeup-only" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
await adapter.promptWithFallback(session, "p1");
|
||||
expect(mockCreateIssue).not.toHaveBeenCalled();
|
||||
expect(mockWakeAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaperclipRuntimeAdapter — wakeup soft errors", () => {
|
||||
it("status=skipped → onToolEnd isError=true, no polling", async () => {
|
||||
mockWakeAgent.mockResolvedValueOnce({ id: "", status: "skipped" });
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onToolEnd = vi.fn();
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onToolEnd).toHaveBeenCalledWith(
|
||||
"paperclip.run",
|
||||
true,
|
||||
expect.objectContaining({ runStatus: "skipped" }),
|
||||
);
|
||||
expect(mockGetRunEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promptWithFallback creates issue, checks out, invokes heartbeat, polls, and emits output", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
it("wakeup throws → onToolEnd isError=true, no polling", async () => {
|
||||
mockWakeAgent.mockRejectedValueOnce(new Error("nope"));
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onToolEnd = vi.fn();
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onToolEnd).toHaveBeenCalledWith(
|
||||
"paperclip.run",
|
||||
true,
|
||||
expect.objectContaining({ reason: expect.stringContaining("nope") }),
|
||||
);
|
||||
expect(mockGetRunEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
describe("PaperclipRuntimeAdapter — terminal statuses", () => {
|
||||
it("succeeded → onToolEnd isError=false", async () => {
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onToolEnd = vi.fn();
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onToolEnd).toHaveBeenCalledWith(
|
||||
"paperclip.run",
|
||||
false,
|
||||
expect.objectContaining({ runStatus: "succeeded" }),
|
||||
);
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({
|
||||
cwd: "/repo",
|
||||
systemPrompt: "system prompt",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([
|
||||
{ id: "C1", body: "Thinking: I should do this" },
|
||||
{ id: "C2", body: "Completed work." },
|
||||
it("failed → onToolEnd isError=true", async () => {
|
||||
mockGetRunEvents.mockResolvedValueOnce([
|
||||
{ seq: 1, type: "heartbeat.run.status", payload: { status: "failed" } },
|
||||
]);
|
||||
|
||||
const promptPromise = adapter.promptWithFallback(session, "Title line\nBody");
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await promptPromise;
|
||||
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith(
|
||||
"http://paperclip.local",
|
||||
"token",
|
||||
"CO-1",
|
||||
expect.objectContaining({
|
||||
title: "Title line",
|
||||
status: "backlog",
|
||||
assigneeAgentId: "AG-1",
|
||||
}),
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onToolEnd = vi.fn();
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onToolEnd).toHaveBeenCalledWith(
|
||||
"paperclip.run",
|
||||
true,
|
||||
expect.objectContaining({ runStatus: "failed" }),
|
||||
);
|
||||
expect(mockCheckoutIssue).toHaveBeenCalledWith(
|
||||
"http://paperclip.local",
|
||||
"token",
|
||||
"ISS-1",
|
||||
"AG-1",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalledWith("http://paperclip.local", "token", "AG-1");
|
||||
expect(onText).toHaveBeenCalledWith("Thinking: I should do this\n\nCompleted work.");
|
||||
expect(onThinking).toHaveBeenCalledWith("I should do this");
|
||||
expect(onToolStart).toHaveBeenCalledWith(
|
||||
"paperclip.issue",
|
||||
expect.objectContaining({ sessionId: expect.any(String) }),
|
||||
);
|
||||
expect(onToolEnd).toHaveBeenCalledWith("paperclip.issue", false, {
|
||||
issueId: "ISS-1",
|
||||
status: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles checkout conflicts gracefully and continues", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter(
|
||||
{
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockRejectedValue(new MockConflictError("conflict"));
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await promise;
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("checkout conflict"));
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles heartbeat skipped responses and continues polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
it("local timeout → exits with timedOutLocally=true, no throw", async () => {
|
||||
mockGetRunEvents.mockResolvedValue([
|
||||
{ seq: 1, type: "heartbeat.run.status", payload: { status: "running" } },
|
||||
]);
|
||||
const adapter = makeAdapter({
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
runTimeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
pollIntervalMaxMs: 1,
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await promise;
|
||||
|
||||
expect(mockInvokeHeartbeat).toHaveBeenCalled();
|
||||
expect(mockGetIssue).toHaveBeenCalled();
|
||||
const onToolEnd = vi.fn();
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onToolEnd).toHaveBeenCalledWith(
|
||||
"paperclip.run",
|
||||
true,
|
||||
expect.objectContaining({ timedOutLocally: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns output on timeout with whatever comments are available", async () => {
|
||||
vi.useFakeTimers();
|
||||
describe("PaperclipRuntimeAdapter — comment fallback", () => {
|
||||
it("uses latest comment as text when no streamed stdout", async () => {
|
||||
mockGetRunEvents.mockResolvedValueOnce([
|
||||
{ seq: 1, type: "heartbeat.run.status", payload: { status: "succeeded" } },
|
||||
]);
|
||||
mockGetIssueComments.mockResolvedValueOnce([
|
||||
{ id: "C-old", body: "older" },
|
||||
{ id: "C-latest", body: "latest answer" },
|
||||
]);
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const onText = vi.fn();
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system", onText });
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "partial result" }]);
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(130_000);
|
||||
await promise;
|
||||
|
||||
expect(onText).toHaveBeenCalledWith("partial result");
|
||||
});
|
||||
|
||||
it("uses exponential backoff intervals while polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
apiKey: "token",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
|
||||
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
|
||||
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
|
||||
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
|
||||
mockGetIssue
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
|
||||
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
|
||||
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
|
||||
|
||||
const promise = adapter.promptWithFallback(session, "Prompt");
|
||||
await vi.advanceTimersByTimeAsync(2_000 + 4_000 + 8_000 + 10_000 + 10_000);
|
||||
await promise;
|
||||
|
||||
const timeoutDurations = timeoutSpy.mock.calls.map((call) => call[1]).filter((value) => typeof value === "number");
|
||||
expect(timeoutDurations).toEqual(expect.arrayContaining([2_000, 4_000, 8_000, 10_000]));
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts, onText });
|
||||
await adapter.promptWithFallback(session, "p");
|
||||
expect(onText).toHaveBeenCalledWith("latest answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaperclipRuntimeAdapter — describeModel/dispose", () => {
|
||||
it("describeModel returns paperclip/<agentId>", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
expect(adapter.describeModel(session)).toBe("paperclip/AG-1");
|
||||
const adapter = makeAdapter({ agentId: "AG-XYZ", companyId: "CO-1" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
expect(adapter.describeModel(session)).toBe("paperclip/AG-XYZ");
|
||||
});
|
||||
|
||||
it("dispose is a no-op", async () => {
|
||||
const adapter = new PaperclipRuntimeAdapter({
|
||||
apiUrl: "http://paperclip.local",
|
||||
agentId: "AG-1",
|
||||
companyId: "CO-1",
|
||||
});
|
||||
|
||||
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
|
||||
expect(typeof session.dispose).toBe("function");
|
||||
expect(() => session.dispose?.()).not.toThrow();
|
||||
await expect(adapter.dispose(session)).resolves.toBeUndefined();
|
||||
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
|
||||
const { session } = await adapter.createSession({ ...baseSessionOpts });
|
||||
await expect(adapter.dispose!(session)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { probePaperclipInstance, resolvePaperclipConfig } from "./pi-module.js";
|
||||
import {
|
||||
probePaperclipConnection,
|
||||
resolvePaperclipConfig,
|
||||
} from "./paperclip-client.js";
|
||||
import { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
@@ -7,20 +10,46 @@ import type {
|
||||
RuntimeLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// Public exports — consumed by the dashboard probe façade and tests.
|
||||
export type {
|
||||
PaperclipAgentSummary,
|
||||
PaperclipCliDiscovery,
|
||||
PaperclipCliDiscoveryResult,
|
||||
PaperclipCompanySummary,
|
||||
PaperclipConnectionStatus,
|
||||
} from "./paperclip-client.js";
|
||||
export {
|
||||
agentsMe,
|
||||
discoverPaperclipCliConfig,
|
||||
listCompanies,
|
||||
listCompanyAgents,
|
||||
mintAgentApiKeyViaCli,
|
||||
probePaperclipConnection,
|
||||
} from "./paperclip-client.js";
|
||||
export type { MintCliKeyOptions, MintedApiKey } from "./paperclip-client.js";
|
||||
export { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
|
||||
|
||||
function getSettingsConfig(settings: unknown) {
|
||||
return resolvePaperclipConfig((settings ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function paperclipRuntimeFactory(ctx: { settings?: unknown; logger?: RuntimeLogger }): Promise<unknown> {
|
||||
async function paperclipRuntimeFactory(ctx: {
|
||||
settings?: unknown;
|
||||
logger?: RuntimeLogger;
|
||||
}): Promise<unknown> {
|
||||
const config = getSettingsConfig(ctx.settings);
|
||||
return new PaperclipRuntimeAdapter(config, ctx.logger);
|
||||
// resolvePaperclipConfig returns `mode: string`; the adapter narrows it.
|
||||
return new PaperclipRuntimeAdapter(
|
||||
config as unknown as Record<string, unknown>,
|
||||
ctx.logger,
|
||||
);
|
||||
}
|
||||
|
||||
const paperclipRuntime: PluginRuntimeRegistration = {
|
||||
metadata: {
|
||||
runtimeId: "paperclip",
|
||||
name: "Paperclip Runtime",
|
||||
description: "Paperclip-backed AI session via Paperclip REST API",
|
||||
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
factory: paperclipRuntimeFactory,
|
||||
@@ -31,14 +60,14 @@ const plugin: FusionPlugin = definePlugin({
|
||||
id: "fusion-plugin-paperclip-runtime",
|
||||
name: "Paperclip Runtime Plugin",
|
||||
version: "1.0.0",
|
||||
description: "Provides Paperclip runtime for Fusion AI agents",
|
||||
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
|
||||
author: "Fusion Team",
|
||||
homepage: "https://github.com/gsxdsm/fusion",
|
||||
homepage: "https://paperclip.ing/",
|
||||
fusionVersion: ">=0.1.0",
|
||||
runtime: {
|
||||
runtimeId: "paperclip",
|
||||
name: "Paperclip Runtime",
|
||||
description: "Paperclip-backed AI session via Paperclip REST API",
|
||||
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
@@ -49,15 +78,26 @@ const plugin: FusionPlugin = definePlugin({
|
||||
const config = getSettingsConfig(ctx.settings);
|
||||
ctx.logger.info(`Paperclip Runtime Plugin loaded (apiUrl=${config.apiUrl})`);
|
||||
|
||||
const probe = await probePaperclipInstance(config.apiUrl, config.apiKey);
|
||||
if (probe.ok) {
|
||||
ctx.logger.info(
|
||||
`Paperclip probe succeeded (deploymentMode=${probe.deploymentMode ?? "unknown"})`,
|
||||
);
|
||||
return;
|
||||
// Best-effort connectivity probe; failures are warnings, not errors.
|
||||
try {
|
||||
const status = await probePaperclipConnection({
|
||||
apiUrl: config.apiUrl,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
if (status.available) {
|
||||
const ident = status.identity;
|
||||
ctx.logger.info(
|
||||
ident
|
||||
? `Paperclip reachable as ${ident.agentName} (${ident.role ?? "agent"}) at ${ident.companyName ?? ident.companyId}`
|
||||
: `Paperclip reachable at ${config.apiUrl}`,
|
||||
);
|
||||
} else {
|
||||
ctx.logger.warn(`Paperclip probe failed: ${status.reason ?? "unknown"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
ctx.logger.warn(`Paperclip probe threw: ${reason}`);
|
||||
}
|
||||
|
||||
ctx.logger.warn(`Paperclip probe failed: ${probe.error}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
939
plugins/fusion-plugin-paperclip-runtime/src/paperclip-client.ts
Normal file
939
plugins/fusion-plugin-paperclip-runtime/src/paperclip-client.ts
Normal file
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* Paperclip REST API client.
|
||||
*
|
||||
* Low-level HTTP helpers for the Paperclip control-plane API.
|
||||
* This module intentionally does not depend on @fusion/engine or any Fusion
|
||||
* internals — it is a pure HTTP client.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown when a Paperclip API call returns HTTP 409 Conflict.
|
||||
* Usually means another agent already owns the issue being checked out.
|
||||
*/
|
||||
export class ConflictError extends Error {
|
||||
readonly status = 409;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a wakeup call is rejected with HTTP 202 + `status: "skipped"`.
|
||||
* This is a soft error — the agent is already running and the wakeup was
|
||||
* coalesced server-side. No polling should follow.
|
||||
*/
|
||||
export class WakeupSkippedError extends Error {
|
||||
constructor(public readonly runId?: string) {
|
||||
super("Paperclip wakeup was coalesced/skipped — agent already active");
|
||||
this.name = "WakeupSkippedError";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared data shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AgentsMeResponse {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
role?: string;
|
||||
companyId: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface CreateIssueBody {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assigneeAgentId: string;
|
||||
parentId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
}
|
||||
|
||||
export interface WakeAgentBody {
|
||||
source: "on_demand" | "timer" | "assignment" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string;
|
||||
idempotencyKey?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WakeAgentResponse {
|
||||
id: string;
|
||||
status: string; // "queued" | "running" | "skipped" | ...
|
||||
}
|
||||
|
||||
/**
|
||||
* A single event from GET /api/heartbeat-runs/{runId}/events.
|
||||
*
|
||||
* The exact payload shape is not fully documented; we treat it defensively.
|
||||
*/
|
||||
export interface RunEvent {
|
||||
id?: string | number;
|
||||
seq: number;
|
||||
type: string; // e.g. "heartbeat.run.log", "heartbeat.run.status", "adapter.invoke"
|
||||
payload?: {
|
||||
stream?: "stdout" | "stderr" | "system";
|
||||
chunk?: string;
|
||||
message?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeApiUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildUrl(apiUrl: string, path: string, query?: URLSearchParams): string {
|
||||
const base = normalizeApiUrl(apiUrl);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const qs = query && query.size > 0 ? `?${query.toString()}` : "";
|
||||
return `${base}/api${normalizedPath}${qs}`;
|
||||
}
|
||||
|
||||
function buildHeaders(apiKey?: string, extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...extra,
|
||||
};
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function parseJsonBody(response: Response): Promise<{ value: unknown; raw: string }> {
|
||||
const raw = await response.text();
|
||||
if (raw.trim() === "") {
|
||||
return { value: undefined, raw };
|
||||
}
|
||||
try {
|
||||
return { value: JSON.parse(raw), raw };
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Paperclip API ${response.status} ${response.statusText}: invalid JSON response body`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorMessage(status: number, statusText: string, body: unknown, raw: string): string {
|
||||
if (body && typeof body === "object") {
|
||||
const b = body as Record<string, unknown>;
|
||||
if (typeof b.error === "string") return b.error;
|
||||
if (typeof b.message === "string") return b.message;
|
||||
}
|
||||
if (raw.trim() !== "") return raw.slice(0, 200).trim();
|
||||
return `${status} ${statusText}`.trim();
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
apiUrl: string,
|
||||
path: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
apiKey?: string;
|
||||
body?: unknown;
|
||||
query?: URLSearchParams;
|
||||
},
|
||||
): Promise<T> {
|
||||
const method = options?.method ?? "GET";
|
||||
const url = buildUrl(apiUrl, path, options?.query);
|
||||
|
||||
const headers = buildHeaders(options?.apiKey);
|
||||
let bodyStr: string | undefined;
|
||||
if (options && "body" in options && options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
bodyStr = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method, headers, body: bodyStr });
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Paperclip API network error (${method} ${url}): ${reason}`);
|
||||
}
|
||||
|
||||
const { value, raw } = await parseJsonBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = toErrorMessage(response.status, response.statusText, value, raw);
|
||||
const full = `Paperclip API ${response.status} (${method} ${path}): ${msg}`;
|
||||
if (response.status === 409) throw new ConflictError(full);
|
||||
throw new Error(full);
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSettingString(settings: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const v = settings?.[key];
|
||||
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
export interface PaperclipClientConfig {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
mode?: string;
|
||||
transport?: "api" | "cli";
|
||||
cliBinaryPath?: string;
|
||||
cliConfigPath?: string;
|
||||
parentIssueId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
runTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
pollIntervalMaxMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Paperclip client config from plugin settings and environment
|
||||
* variables, with the following precedence:
|
||||
* plugin settings > environment variables > built-in defaults
|
||||
*/
|
||||
export function resolvePaperclipConfig(settings?: Record<string, unknown>): PaperclipClientConfig {
|
||||
const apiUrl = normalizeApiUrl(
|
||||
getSettingString(settings, "apiUrl") ??
|
||||
process.env.PAPERCLIP_API_URL?.trim() ??
|
||||
"http://localhost:3100",
|
||||
);
|
||||
|
||||
const apiKey =
|
||||
getSettingString(settings, "apiKey") ??
|
||||
(process.env.PAPERCLIP_API_KEY?.trim() || undefined);
|
||||
|
||||
const agentId =
|
||||
getSettingString(settings, "agentId") ??
|
||||
process.env.PAPERCLIP_AGENT_ID?.trim() ??
|
||||
undefined;
|
||||
|
||||
const companyId =
|
||||
getSettingString(settings, "companyId") ??
|
||||
process.env.PAPERCLIP_COMPANY_ID?.trim() ??
|
||||
undefined;
|
||||
|
||||
const mode =
|
||||
getSettingString(settings, "mode") ??
|
||||
process.env.PAPERCLIP_RUNTIME_MODE?.trim() ??
|
||||
"rolling-issue";
|
||||
|
||||
const transportRaw =
|
||||
getSettingString(settings, "transport") ??
|
||||
process.env.PAPERCLIP_TRANSPORT?.trim() ??
|
||||
"api";
|
||||
const transport: "api" | "cli" = transportRaw === "cli" ? "cli" : "api";
|
||||
|
||||
const cliBinaryPath =
|
||||
getSettingString(settings, "cliBinaryPath") ??
|
||||
process.env.PAPERCLIPAI_BIN?.trim() ??
|
||||
"paperclipai";
|
||||
|
||||
const cliConfigPath =
|
||||
getSettingString(settings, "cliConfigPath") ??
|
||||
process.env.PAPERCLIP_CLI_CONFIG?.trim() ??
|
||||
undefined;
|
||||
|
||||
const parentIssueId =
|
||||
getSettingString(settings, "parentIssueId") ??
|
||||
process.env.PAPERCLIP_PARENT_ISSUE_ID?.trim() ??
|
||||
undefined;
|
||||
|
||||
const projectId =
|
||||
getSettingString(settings, "projectId") ??
|
||||
process.env.PAPERCLIP_PROJECT_ID?.trim() ??
|
||||
undefined;
|
||||
|
||||
const goalId =
|
||||
getSettingString(settings, "goalId") ??
|
||||
process.env.PAPERCLIP_GOAL_ID?.trim() ??
|
||||
undefined;
|
||||
|
||||
const runTimeoutMs = parseInt(
|
||||
getSettingString(settings, "runTimeoutMs") ??
|
||||
process.env.PAPERCLIP_RUN_TIMEOUT_MS ??
|
||||
"600000",
|
||||
10,
|
||||
);
|
||||
|
||||
const pollIntervalMs = parseInt(
|
||||
getSettingString(settings, "pollIntervalMs") ??
|
||||
process.env.PAPERCLIP_POLL_INTERVAL_MS ??
|
||||
"500",
|
||||
10,
|
||||
);
|
||||
|
||||
const pollIntervalMaxMs = parseInt(
|
||||
getSettingString(settings, "pollIntervalMaxMs") ??
|
||||
process.env.PAPERCLIP_POLL_INTERVAL_MAX_MS ??
|
||||
"2000",
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
agentId,
|
||||
companyId,
|
||||
mode,
|
||||
transport,
|
||||
cliBinaryPath,
|
||||
cliConfigPath,
|
||||
parentIssueId,
|
||||
projectId,
|
||||
goalId,
|
||||
runTimeoutMs: isFinite(runTimeoutMs) ? runTimeoutMs : 600_000,
|
||||
pollIntervalMs: isFinite(pollIntervalMs) ? pollIntervalMs : 500,
|
||||
pollIntervalMaxMs: isFinite(pollIntervalMaxMs) ? pollIntervalMaxMs : 2_000,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI-mode auth discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of discovering Paperclip auth from a local `paperclipai` install.
|
||||
*/
|
||||
export interface PaperclipCliDiscovery {
|
||||
/** Resolved API URL, e.g. "http://127.0.0.1:3100". */
|
||||
apiUrl: string;
|
||||
/** API key, or undefined for local-trusted deployments. */
|
||||
apiKey?: string;
|
||||
/** Path to the config file the discovery read from. */
|
||||
configPath: string;
|
||||
/** Deployment mode reported by the config (e.g. "local_trusted", "cloud"). */
|
||||
deploymentMode?: string;
|
||||
}
|
||||
|
||||
export interface PaperclipCliDiscoveryError {
|
||||
ok: false;
|
||||
reason: string;
|
||||
/** The path we tried to read, if any. */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export type PaperclipCliDiscoveryResult =
|
||||
| ({ ok: true } & PaperclipCliDiscovery)
|
||||
| PaperclipCliDiscoveryError;
|
||||
|
||||
/**
|
||||
* Attempt to discover paperclipai's apiUrl + apiKey from its local config file.
|
||||
*
|
||||
* Reads `~/.paperclip/instances/default/config.json` by default. When
|
||||
* `deploymentMode === "local_trusted"`, no apiKey is needed (trusted-localhost
|
||||
* mode skips auth). For other modes, an apiKey must still be configured by the
|
||||
* user; this function reports `ok: true` with the URL but `apiKey: undefined`,
|
||||
* leaving the caller to decide whether to fall back to API-mode auth.
|
||||
*
|
||||
* Why a separate function: keeps the discovery logic testable and lets callers
|
||||
* distinguish "no paperclipai install" from "install present but config absent".
|
||||
*/
|
||||
export async function discoverPaperclipCliConfig(
|
||||
opts: { configPath?: string } = {},
|
||||
): Promise<PaperclipCliDiscoveryResult> {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const configPath =
|
||||
opts.configPath ??
|
||||
path.join(os.homedir(), ".paperclip", "instances", "default", "config.json");
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(configPath, "utf-8");
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
ok: false,
|
||||
configPath,
|
||||
reason:
|
||||
code === "ENOENT"
|
||||
? `Paperclip CLI config not found at ${configPath}. Install paperclipai and run \`paperclipai onboard\`, or use API mode.`
|
||||
: `Could not read ${configPath}: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { ok: false, configPath, reason: `Invalid JSON in ${configPath}` };
|
||||
}
|
||||
|
||||
const server = (parsed.server ?? {}) as Record<string, unknown>;
|
||||
const host = typeof server.host === "string" ? server.host : "127.0.0.1";
|
||||
const port =
|
||||
typeof server.port === "number"
|
||||
? server.port
|
||||
: Number(server.port ?? 3100) || 3100;
|
||||
const deploymentMode =
|
||||
typeof server.deploymentMode === "string" ? server.deploymentMode : undefined;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
apiUrl: `http://${host}:${port}`,
|
||||
apiKey: undefined,
|
||||
configPath,
|
||||
deploymentMode,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paperclip REST helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the identity of the API key owner.
|
||||
* Used to auto-derive agentId and companyId when they are not configured.
|
||||
*
|
||||
* @throws if the server returns an unexpected error (non-401/403/404/ECONNREFUSED).
|
||||
*/
|
||||
export async function agentsMe(
|
||||
apiUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<AgentsMeResponse> {
|
||||
const raw = await request<Record<string, unknown>>(apiUrl, "/agents/me", { apiKey });
|
||||
const id = typeof raw.id === "string" ? raw.id : undefined;
|
||||
const name = typeof raw.name === "string" ? raw.name : undefined;
|
||||
const role = typeof raw.role === "string" ? raw.role : undefined;
|
||||
const cId = typeof raw.companyId === "string" ? raw.companyId : undefined;
|
||||
const cName = typeof raw.companyName === "string" ? raw.companyName : undefined;
|
||||
if (!id || !cId) {
|
||||
throw new Error("Paperclip /api/agents/me returned a response missing `id` or `companyId`");
|
||||
}
|
||||
return { agentId: id, agentName: name ?? id, role, companyId: cId, companyName: cName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Paperclip issue and returns its `id`.
|
||||
*/
|
||||
export async function createIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
body: CreateIssueBody,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/companies/${companyId}/issues`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single issue by ID.
|
||||
*/
|
||||
export async function getIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches comments on an issue.
|
||||
*/
|
||||
export async function getIssueComments(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return request<Array<Record<string, unknown>>>(apiUrl, `/issues/${issueId}/comments`, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a wakeup for an agent. Returns the created run (or a skipped
|
||||
* response).
|
||||
*
|
||||
* Callers should check `response.status === "skipped"` and handle accordingly;
|
||||
* this function does NOT throw for skipped — it returns the raw response.
|
||||
*/
|
||||
export async function wakeAgent(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
agentId: string,
|
||||
body: WakeAgentBody,
|
||||
): Promise<WakeAgentResponse> {
|
||||
return request<WakeAgentResponse>(apiUrl, `/agents/${agentId}/wakeup`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches lightweight run events, starting after `afterSeq`.
|
||||
*
|
||||
* The API endpoint is `GET /api/heartbeat-runs/{runId}/events?afterSeq=N&limit=L`.
|
||||
*/
|
||||
export async function getRunEvents(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
runId: string,
|
||||
afterSeq: number,
|
||||
limit = 100,
|
||||
): Promise<RunEvent[]> {
|
||||
const query = new URLSearchParams({
|
||||
afterSeq: String(afterSeq),
|
||||
limit: String(limit),
|
||||
});
|
||||
const result = await request<unknown>(apiUrl, `/heartbeat-runs/${runId}/events`, {
|
||||
apiKey,
|
||||
query,
|
||||
});
|
||||
// Accept both { events: RunEvent[] } and RunEvent[] response shapes
|
||||
if (Array.isArray(result)) return result as RunEvent[];
|
||||
const r = result as Record<string, unknown>;
|
||||
if (Array.isArray(r.events)) return r.events as RunEvent[];
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Company discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaperclipCompanySummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Optional URL slug used in deep-links; not always present. */
|
||||
urlKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists companies visible to the bearer. Backed by `GET /api/companies`.
|
||||
*
|
||||
* For local-trusted deployments without auth, returns every company on the
|
||||
* instance. For agent-key-scoped requests, returns just the agent's company.
|
||||
*/
|
||||
export async function listCompanies(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
): Promise<PaperclipCompanySummary[]> {
|
||||
const raw = await request<unknown>(apiUrl, "/companies", { apiKey });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: PaperclipCompanySummary[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const r = entry as Record<string, unknown>;
|
||||
const id = typeof r.id === "string" ? r.id : undefined;
|
||||
if (!id) continue;
|
||||
const name = typeof r.name === "string" ? r.name : id;
|
||||
const urlKey =
|
||||
typeof r.urlKey === "string"
|
||||
? r.urlKey
|
||||
: typeof r.slug === "string"
|
||||
? r.slug
|
||||
: undefined;
|
||||
out.push({ id, name, urlKey });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent discovery (lets the dashboard show a dropdown of real agents)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaperclipAgentSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
companyId: string;
|
||||
status?: string;
|
||||
/** True when this is the agent that owns the current API key. */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all agents visible to the API key in the given company.
|
||||
*
|
||||
* Backed by `GET /api/companies/{companyId}/agents`. An agent API key can
|
||||
* see siblings in its own company (the server enforces company access via
|
||||
* `assertCompanyAccess`). Returned objects are the full agent records;
|
||||
* we project them down to the fields the dashboard actually renders.
|
||||
*/
|
||||
export async function listCompanyAgents(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
): Promise<PaperclipAgentSummary[]> {
|
||||
const raw = await request<unknown>(apiUrl, `/companies/${companyId}/agents`, { apiKey });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: PaperclipAgentSummary[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const r = entry as Record<string, unknown>;
|
||||
const id = typeof r.id === "string" ? r.id : undefined;
|
||||
if (!id) continue;
|
||||
const name = typeof r.name === "string" ? r.name : id;
|
||||
const role = typeof r.role === "string" ? r.role : undefined;
|
||||
const cId = typeof r.companyId === "string" ? r.companyId : companyId;
|
||||
const status = typeof r.status === "string" ? r.status : undefined;
|
||||
out.push({ id, name, role, companyId: cId, status });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Probe function (public introspection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PaperclipConnectionStatus {
|
||||
available: boolean;
|
||||
apiUrl: string;
|
||||
identity?: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
role?: string;
|
||||
companyId: string;
|
||||
companyName?: string;
|
||||
};
|
||||
/** Human-readable failure reason when `available === false`. */
|
||||
reason?: string;
|
||||
probeDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes a Paperclip server at `apiUrl` using `GET /api/agents/me`.
|
||||
*
|
||||
* - `200` with valid body → `{ available: true, identity: ... }`
|
||||
* - `401` / `403` → `{ available: false, reason: "API key rejected" }`
|
||||
* - `404` / network error → `{ available: false, reason: "Paperclip server not reachable at <url>" }`
|
||||
* - Anything else → `{ available: false, reason: "<status> <first 200 chars of body>" }`
|
||||
*/
|
||||
export async function probePaperclipConnection(opts: {
|
||||
apiUrl: string;
|
||||
apiKey?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<PaperclipConnectionStatus> {
|
||||
const { apiKey, timeoutMs = 5_000 } = opts;
|
||||
const apiUrl = normalizeApiUrl(opts.apiUrl);
|
||||
const url = buildUrl(apiUrl, "/agents/me");
|
||||
const started = Date.now();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(apiKey),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
available: false,
|
||||
apiUrl,
|
||||
reason: `Paperclip server not reachable at ${apiUrl}: ${reason}`,
|
||||
probeDurationMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const probeDurationMs = Date.now() - started;
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { available: false, apiUrl, reason: "API key rejected", probeDurationMs };
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
available: false,
|
||||
apiUrl,
|
||||
reason: `Paperclip server not reachable at ${apiUrl}: 404 Not Found`,
|
||||
probeDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body = "";
|
||||
try {
|
||||
body = (await response.text()).slice(0, 200);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
apiUrl,
|
||||
reason: `${response.status} ${response.statusText}: ${body}`.trim(),
|
||||
probeDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
let identity: PaperclipConnectionStatus["identity"];
|
||||
try {
|
||||
const { value: raw } = await parseJsonBody(response);
|
||||
const r = raw as Record<string, unknown>;
|
||||
const agentId = typeof r.id === "string" ? r.id : "";
|
||||
const companyId = typeof r.companyId === "string" ? r.companyId : "";
|
||||
identity = {
|
||||
agentId,
|
||||
agentName: typeof r.name === "string" ? r.name : agentId,
|
||||
role: typeof r.role === "string" ? r.role : undefined,
|
||||
companyId,
|
||||
companyName: typeof r.companyName === "string" ? r.companyName : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
apiUrl,
|
||||
reason: "Paperclip server responded 200 but returned invalid JSON",
|
||||
probeDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
return { available: true, apiUrl, identity, probeDurationMs };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI key minting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MintCliKeyOptions {
|
||||
/** Path to paperclipai binary or "paperclipai" on PATH. */
|
||||
cliBinaryPath?: string;
|
||||
/** Required: the Paperclip agent ID or shortname. */
|
||||
agentRef: string;
|
||||
/** Required: the Paperclip company ID (paperclipai's local-cli requires -C). */
|
||||
companyId: string;
|
||||
/** Optional: name for the new key (default "fusion-runtime"). */
|
||||
keyName?: string;
|
||||
/** Optional: override the paperclipai config path. */
|
||||
configPath?: string;
|
||||
/** Optional: override the paperclipai data dir. */
|
||||
dataDir?: string;
|
||||
/** Hard-kill timeout for the spawn (default 30_000 ms). */
|
||||
cliTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MintedApiKey {
|
||||
apiKey: string;
|
||||
apiBase?: string;
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
/** Raw JSON the CLI emitted, for diagnostics. */
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
/** Strip ANSI escape sequences from a string. */
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1B\[[0-9;]*[mGKJHFABCDSTsu]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an agent API key by spawning `paperclipai agent local-cli <agentRef> --json --no-install-skills`.
|
||||
*
|
||||
* Requires the local `paperclipai` CLI to be onboarded (`~/.paperclip/context.json`
|
||||
* must have an authenticated profile). If not onboarded, throws with a hint to
|
||||
* run `paperclipai onboard`.
|
||||
*/
|
||||
export async function mintAgentApiKeyViaCli(opts: MintCliKeyOptions): Promise<MintedApiKey> {
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
const bin = opts.cliBinaryPath ?? "paperclipai";
|
||||
const args: string[] = [
|
||||
"agent",
|
||||
"local-cli",
|
||||
opts.agentRef,
|
||||
"--json",
|
||||
"--no-install-skills",
|
||||
"--key-name",
|
||||
opts.keyName ?? "fusion-runtime",
|
||||
];
|
||||
if (opts.companyId) {
|
||||
args.push("--company-id", opts.companyId);
|
||||
}
|
||||
if (opts.configPath) {
|
||||
args.push("--config", opts.configPath);
|
||||
}
|
||||
if (opts.dataDir) {
|
||||
args.push("--data-dir", opts.dataDir);
|
||||
}
|
||||
|
||||
const timeoutMs = opts.cliTimeoutMs ?? 30_000;
|
||||
|
||||
return new Promise<MintedApiKey>((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
`paperclipai binary not found at ${bin}; install via \`npm i -g paperclipai\``,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrLines: string[] = [];
|
||||
let killed = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killed = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`paperclipai agent local-cli timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const lines = chunk.toString("utf-8").split("\n");
|
||||
for (const line of lines) {
|
||||
const stripped = stripAnsi(line).trim();
|
||||
if (stripped) stderrLines.push(stripped);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
clearTimeout(timer);
|
||||
if (err.code === "ENOENT") {
|
||||
reject(
|
||||
new Error(
|
||||
`paperclipai binary not found at ${bin}; install via \`npm i -g paperclipai\``,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
if (killed) return;
|
||||
|
||||
const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
||||
const cleanedStdout = stripAnsi(rawStdout).trim();
|
||||
|
||||
if (code !== 0) {
|
||||
const lastStderrLine = stderrLines.filter(Boolean).pop() ?? "";
|
||||
const hint = "run `paperclipai onboard` to authenticate the CLI";
|
||||
const msg = lastStderrLine
|
||||
? `paperclipai agent local-cli exited ${code}: ${lastStderrLine} — ${hint}`
|
||||
: `paperclipai agent local-cli exited ${code} — ${hint}`;
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cleanedStdout) {
|
||||
const hint = "run `paperclipai onboard` to authenticate the CLI";
|
||||
reject(
|
||||
new Error(`paperclipai agent local-cli produced no output — ${hint}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleanedStdout);
|
||||
} catch {
|
||||
reject(
|
||||
new Error(
|
||||
`paperclipai agent local-cli returned non-JSON output: ${cleanedStdout.slice(0, 200)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = parsed as Record<string, unknown>;
|
||||
// Be defensive about field casing: try apiKey, api_key, token
|
||||
const apiKey =
|
||||
(typeof r.apiKey === "string" ? r.apiKey : undefined) ??
|
||||
(typeof r.api_key === "string" ? r.api_key : undefined) ??
|
||||
(typeof r.token === "string" ? r.token : undefined);
|
||||
|
||||
if (!apiKey) {
|
||||
reject(
|
||||
new Error(
|
||||
`paperclipai agent local-cli JSON missing apiKey/api_key/token field: ${cleanedStdout.slice(0, 200)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase =
|
||||
(typeof r.apiBase === "string" ? r.apiBase : undefined) ??
|
||||
(typeof r.api_base === "string" ? r.api_base : undefined);
|
||||
|
||||
const agentId =
|
||||
(typeof r.agentId === "string" ? r.agentId : undefined) ??
|
||||
(typeof r.id === "string" ? r.id : undefined);
|
||||
|
||||
const companyId =
|
||||
typeof r.companyId === "string" ? r.companyId : undefined;
|
||||
|
||||
resolve({ apiKey, apiBase, agentId, companyId, raw: parsed });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy probe (used by index.ts onLoad)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: true;
|
||||
deploymentMode: string | undefined;
|
||||
}
|
||||
|
||||
export interface ProbeFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ProbePaperclipResult = ProbeResult | ProbeFailure;
|
||||
|
||||
/**
|
||||
* Quick health-check for the Paperclip server via `GET /api/health`.
|
||||
* Used by the plugin's `onLoad` hook.
|
||||
*/
|
||||
export async function probePaperclipInstance(
|
||||
apiUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<ProbePaperclipResult> {
|
||||
try {
|
||||
const result = await request<{ status?: string; deploymentMode?: string }>(
|
||||
apiUrl,
|
||||
"/health",
|
||||
{ apiKey },
|
||||
);
|
||||
if (result.status !== "ok") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Paperclip health check did not return ok status${result.status ? ` (status=${result.status})` : ""}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, deploymentMode: result.deploymentMode };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* Paperclip REST API client.
|
||||
*
|
||||
* This module intentionally does not depend on @fusion/engine.
|
||||
*/
|
||||
|
||||
export interface PaperclipConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string | undefined;
|
||||
agentId: string | undefined;
|
||||
companyId: string | undefined;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
ok: true;
|
||||
deploymentMode: string | undefined;
|
||||
}
|
||||
|
||||
export interface ProbeFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type ProbePaperclipResult = ProbeResult | ProbeFailure;
|
||||
|
||||
export interface AgentIdentity {
|
||||
id: string;
|
||||
name?: string;
|
||||
companyId: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export type AgentIdentityResult =
|
||||
| { ok: true; agent: AgentIdentity }
|
||||
| { ok: false; reason: "unauthenticated" | "not_agent" };
|
||||
|
||||
export interface ListIssuesFilters {
|
||||
status?: string | string[];
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export class ConflictError extends Error {
|
||||
readonly status = 409;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedBody {
|
||||
value: unknown;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
function normalizeApiUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getSettingString(settings: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = settings?.[key];
|
||||
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function buildApiUrl(apiUrl: string, path: string): string {
|
||||
const base = normalizeApiUrl(apiUrl);
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}/api${normalizedPath}`;
|
||||
}
|
||||
|
||||
function toErrorMessage(status: number, statusText: string, body: unknown, rawBody: string): string {
|
||||
if (body && typeof body === "object") {
|
||||
if (typeof (body as { error?: unknown }).error === "string") {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
if (typeof (body as { message?: unknown }).message === "string") {
|
||||
return (body as { message: string }).message;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawBody.trim() !== "") {
|
||||
return rawBody.trim();
|
||||
}
|
||||
|
||||
return `${status} ${statusText}`.trim();
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<ParsedBody> {
|
||||
const raw = await response.text();
|
||||
|
||||
if (raw.trim() === "") {
|
||||
return { value: undefined, raw };
|
||||
}
|
||||
|
||||
try {
|
||||
return { value: JSON.parse(raw), raw };
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Paperclip API ${response.status} ${response.statusText}: invalid JSON response body`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
apiUrl: string,
|
||||
path: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
apiKey?: string;
|
||||
body?: unknown;
|
||||
runId?: string;
|
||||
query?: URLSearchParams;
|
||||
},
|
||||
): Promise<T> {
|
||||
const method = options?.method ?? "GET";
|
||||
const url = `${buildApiUrl(apiUrl, path)}${options?.query ? `?${options.query.toString()}` : ""}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (options?.apiKey) {
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
if (options?.runId) {
|
||||
headers["X-Paperclip-Run-Id"] = options.runId;
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (options && "body" in options && options.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method, headers, body });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Paperclip API network error (${method} ${url}): ${reason}`);
|
||||
}
|
||||
|
||||
const parsed = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
|
||||
const errorMessage = `Paperclip API ${response.status} (${method} ${path}): ${message}`;
|
||||
if (response.status === 409) {
|
||||
throw new ConflictError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return parsed.value as T;
|
||||
}
|
||||
|
||||
export function resolvePaperclipConfig(settings?: Record<string, unknown>): PaperclipConfig {
|
||||
const apiUrl =
|
||||
getSettingString(settings, "apiUrl") ??
|
||||
process.env.PAPERCLIP_API_URL?.trim() ??
|
||||
"http://localhost:3100";
|
||||
|
||||
const envApiKey = process.env.PAPERCLIP_API_KEY?.trim() || undefined;
|
||||
const envAgentId = process.env.PAPERCLIP_AGENT_ID?.trim() || undefined;
|
||||
const envCompanyId = process.env.PAPERCLIP_COMPANY_ID?.trim() || undefined;
|
||||
|
||||
return {
|
||||
apiUrl: normalizeApiUrl(apiUrl),
|
||||
apiKey: getSettingString(settings, "apiKey") ?? envApiKey,
|
||||
agentId: getSettingString(settings, "agentId") ?? envAgentId,
|
||||
companyId: getSettingString(settings, "companyId") ?? envCompanyId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probePaperclipInstance(
|
||||
apiUrl: string,
|
||||
apiKey?: string,
|
||||
): Promise<ProbePaperclipResult> {
|
||||
try {
|
||||
const result = await request<{ status?: string; deploymentMode?: string }>(apiUrl, "/health", {
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (result.status !== "ok") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Paperclip health check did not return ok status${
|
||||
result.status ? ` (status=${result.status})` : ""
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, deploymentMode: result.deploymentMode };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentIdentity(apiUrl: string, apiKey?: string): Promise<AgentIdentityResult> {
|
||||
const url = buildApiUrl(apiUrl, "/agents/me");
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method: "GET", headers });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Paperclip API network error (GET ${url}): ${reason}`);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return { ok: false, reason: "unauthenticated" };
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return { ok: false, reason: "not_agent" };
|
||||
}
|
||||
|
||||
const parsed = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
|
||||
throw new Error(`Paperclip API ${response.status} (GET /agents/me): ${message}`);
|
||||
}
|
||||
|
||||
const agent = parsed.value as Partial<AgentIdentity>;
|
||||
if (!agent.id || !agent.companyId) {
|
||||
throw new Error("Paperclip API returned invalid agent identity response");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
companyId: agent.companyId,
|
||||
role: agent.role,
|
||||
status: agent.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listIssues(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
filters?: ListIssuesFilters,
|
||||
): Promise<unknown[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (filters?.status) {
|
||||
query.set("status", Array.isArray(filters.status) ? filters.status.join(",") : filters.status);
|
||||
}
|
||||
if (filters?.assigneeAgentId) {
|
||||
query.set("assigneeAgentId", filters.assigneeAgentId);
|
||||
}
|
||||
if (filters?.projectId) {
|
||||
query.set("projectId", filters.projectId);
|
||||
}
|
||||
|
||||
return request<unknown[]>(apiUrl, `/companies/${companyId}/issues`, {
|
||||
apiKey,
|
||||
query: query.size > 0 ? query : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, { apiKey });
|
||||
}
|
||||
|
||||
export async function createIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
companyId: string,
|
||||
issue: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
assigneeAgentId: string;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/companies/${companyId}/issues`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: issue,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
patch: Record<string, unknown>,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, {
|
||||
method: "PATCH",
|
||||
apiKey,
|
||||
body: patch,
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkoutIssue(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
agentId: string,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/checkout`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: {
|
||||
agentId,
|
||||
expectedStatuses: ["todo", "backlog"],
|
||||
},
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIssueComments(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
return request<Array<Record<string, unknown>>>(apiUrl, `/issues/${issueId}/comments`, { apiKey });
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
issueId: string,
|
||||
body: string,
|
||||
runId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/comments`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
body: { body },
|
||||
runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function invokeHeartbeat(
|
||||
apiUrl: string,
|
||||
apiKey: string | undefined,
|
||||
agentId: string,
|
||||
): Promise<{ ok: true; skipped: true } | { ok: true; run: Record<string, unknown> }> {
|
||||
const result = await request<Record<string, unknown>>(apiUrl, `/agents/${agentId}/heartbeat/invoke`, {
|
||||
method: "POST",
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (result.status === "skipped") {
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
return { ok: true, run: result };
|
||||
}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ConflictError,
|
||||
agentsMe,
|
||||
createIssue,
|
||||
checkoutIssue,
|
||||
discoverPaperclipCliConfig,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
invokeHeartbeat,
|
||||
getRunEvents,
|
||||
resolvePaperclipConfig,
|
||||
} from "./pi-module.js";
|
||||
wakeAgent,
|
||||
type RunEvent,
|
||||
} from "./paperclip-client.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSessionResult,
|
||||
PaperclipMode,
|
||||
PaperclipRuntimeConfig,
|
||||
PaperclipSession,
|
||||
RuntimeLogger,
|
||||
} from "./types.js";
|
||||
|
||||
const POLL_INITIAL_INTERVAL_MS = 2_000;
|
||||
const POLL_MAX_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 120_000;
|
||||
const TERMINAL_STATUSES = new Set(["done", "cancelled", "in_review"]);
|
||||
/** Run-level statuses that signal we should stop polling events. */
|
||||
const TERMINAL_RUN_STATUSES = new Set<string>([
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
]);
|
||||
|
||||
const VALID_MODES: ReadonlySet<PaperclipMode> = new Set([
|
||||
"issue-per-prompt",
|
||||
"rolling-issue",
|
||||
"wakeup-only",
|
||||
]);
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -31,7 +43,8 @@ function asString(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function deriveIssueTitle(prompt: string): string {
|
||||
const firstLine = prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
|
||||
const firstLine =
|
||||
prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
|
||||
return firstLine.slice(0, 200);
|
||||
}
|
||||
|
||||
@@ -43,35 +56,6 @@ function buildIssueDescription(session: PaperclipSession, prompt: string): strin
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function collectCommentText(comments: Array<Record<string, unknown>>): { text: string; thinking: string } {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const body = asString(comment.body)?.trim();
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textParts.push(body);
|
||||
|
||||
const kind = asString(comment.kind) ?? asString(comment.type);
|
||||
if (kind === "thinking" || kind === "reasoning") {
|
||||
thinkingParts.push(body);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body.toLowerCase().startsWith("thinking:")) {
|
||||
thinkingParts.push(body.replace(/^thinking:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join("\n\n"),
|
||||
thinking: thinkingParts.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function pickIssueId(issue: Record<string, unknown>): string {
|
||||
const issueId = asString(issue.id);
|
||||
if (!issueId) {
|
||||
@@ -80,10 +64,20 @@ function pickIssueId(issue: Record<string, unknown>): string {
|
||||
return issueId;
|
||||
}
|
||||
|
||||
function pickIssueStatus(issue: Record<string, unknown>): string {
|
||||
return asString(issue.status) ?? "unknown";
|
||||
function normalizeMode(mode: string | undefined): PaperclipMode {
|
||||
if (mode && VALID_MODES.has(mode as PaperclipMode)) return mode as PaperclipMode;
|
||||
return "rolling-issue";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that drives Paperclip via its modern wakeup + heartbeat-run streaming API.
|
||||
*
|
||||
* Flow per prompt:
|
||||
* 1. Optional issue creation/reuse (depending on `mode`).
|
||||
* 2. POST /api/agents/{agentId}/wakeup with idempotencyKey + payload.
|
||||
* 3. Stream GET /api/heartbeat-runs/{runId}/events; forward log chunks.
|
||||
* 4. Once terminal, fetch the issue + comments for a final answer.
|
||||
*/
|
||||
export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "paperclip";
|
||||
readonly name = "Paperclip Runtime";
|
||||
@@ -92,31 +86,82 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
private readonly logger: RuntimeLogger;
|
||||
|
||||
constructor(config?: Partial<PaperclipRuntimeConfig>, logger?: RuntimeLogger) {
|
||||
const resolved = resolvePaperclipConfig(
|
||||
config as Record<string, unknown> | undefined,
|
||||
);
|
||||
// resolvePaperclipConfig returns mode as string; narrow at the boundary.
|
||||
this.config = {
|
||||
...resolvePaperclipConfig(config as Record<string, unknown> | undefined),
|
||||
...resolved,
|
||||
mode: normalizeMode(resolved.mode),
|
||||
...config,
|
||||
};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
if (!this.config.agentId || !this.config.companyId) {
|
||||
const missing = [!this.config.agentId ? "agentId" : null, !this.config.companyId ? "companyId" : null]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
let effectiveApiUrl = this.config.apiUrl;
|
||||
let effectiveApiKey = this.config.apiKey;
|
||||
|
||||
// CLI transport: read apiUrl (and possibly apiKey) from local paperclipai config.
|
||||
if (this.config.transport === "cli") {
|
||||
const discovery = await discoverPaperclipCliConfig({
|
||||
configPath: this.config.cliConfigPath,
|
||||
});
|
||||
if (!discovery.ok) {
|
||||
throw new Error(
|
||||
`Paperclip CLI mode failed: ${discovery.reason} (Switch to API mode in settings if paperclipai isn't installed.)`,
|
||||
);
|
||||
}
|
||||
effectiveApiUrl = discovery.apiUrl;
|
||||
// Only override apiKey if the user didn't explicitly set one.
|
||||
if (!effectiveApiKey) {
|
||||
effectiveApiKey = discovery.apiKey;
|
||||
}
|
||||
this.logger.info(
|
||||
`Paperclip CLI mode resolved apiUrl=${effectiveApiUrl} (deploymentMode=${discovery.deploymentMode ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
let agentId = this.config.agentId;
|
||||
let companyId = this.config.companyId;
|
||||
|
||||
// Auto-derive agentId/companyId from /agents/me when missing.
|
||||
if (!agentId || !companyId) {
|
||||
try {
|
||||
const me = await agentsMe(effectiveApiUrl, effectiveApiKey);
|
||||
agentId = agentId ?? me.agentId;
|
||||
companyId = companyId ?? me.companyId;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Paperclip runtime could not derive agentId/companyId from /agents/me. Configure them explicitly or check the API key. Underlying error: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentId || !companyId) {
|
||||
throw new Error(
|
||||
`Paperclip runtime is missing required config: ${missing}. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* environment variables.`,
|
||||
"Paperclip runtime is missing required config: agentId or companyId. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const session: PaperclipSession = {
|
||||
apiUrl: this.config.apiUrl,
|
||||
apiKey: this.config.apiKey,
|
||||
agentId: this.config.agentId,
|
||||
companyId: this.config.companyId,
|
||||
apiUrl: effectiveApiUrl,
|
||||
apiKey: effectiveApiKey,
|
||||
agentId,
|
||||
companyId,
|
||||
sessionId: randomUUID(),
|
||||
systemPrompt: options.systemPrompt,
|
||||
cwd: options.cwd,
|
||||
mode: normalizeMode(this.config.mode),
|
||||
parentIssueId: this.config.parentIssueId,
|
||||
projectId: this.config.projectId,
|
||||
goalId: this.config.goalId,
|
||||
issueId: undefined,
|
||||
turnIndex: 0,
|
||||
runTimeoutMs: this.config.runTimeoutMs ?? 600_000,
|
||||
pollIntervalMs: this.config.pollIntervalMs ?? 500,
|
||||
pollIntervalMaxMs: this.config.pollIntervalMaxMs ?? 2_000,
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
@@ -124,10 +169,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionFile: undefined,
|
||||
};
|
||||
return { session, sessionFile: undefined };
|
||||
}
|
||||
|
||||
async promptWithFallback(
|
||||
@@ -135,53 +177,104 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
prompt: string,
|
||||
_options?: unknown,
|
||||
): Promise<void> {
|
||||
session.onToolStart?.("paperclip.issue", { sessionId: session.sessionId });
|
||||
|
||||
const createdIssue = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
|
||||
title: deriveIssueTitle(prompt),
|
||||
description: buildIssueDescription(session, prompt),
|
||||
status: "backlog",
|
||||
assigneeAgentId: session.agentId,
|
||||
session.turnIndex += 1;
|
||||
const turn = session.turnIndex;
|
||||
session.onToolStart?.("paperclip.run", {
|
||||
sessionId: session.sessionId,
|
||||
mode: session.mode,
|
||||
turn,
|
||||
});
|
||||
|
||||
const issueId = pickIssueId(createdIssue);
|
||||
// ---- Stage 1: issue create/reuse ------------------------------------
|
||||
let issueId: string | undefined;
|
||||
if (session.mode === "issue-per-prompt") {
|
||||
issueId = await this.createIssueForPrompt(session, prompt);
|
||||
} else if (session.mode === "rolling-issue") {
|
||||
if (!session.issueId) {
|
||||
session.issueId = await this.createIssueForPrompt(session, prompt);
|
||||
}
|
||||
issueId = session.issueId;
|
||||
}
|
||||
// wakeup-only: no issue side-effect.
|
||||
|
||||
// ---- Stage 2: wakeup -------------------------------------------------
|
||||
const idempotencyKey = `${session.sessionId}:${turn}`;
|
||||
let runId: string;
|
||||
try {
|
||||
await checkoutIssue(session.apiUrl, session.apiKey, issueId, session.agentId, session.sessionId);
|
||||
const wakeResponse = await wakeAgent(session.apiUrl, session.apiKey, session.agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "Fusion runtime prompt",
|
||||
idempotencyKey,
|
||||
payload: {
|
||||
fusionSessionId: session.sessionId,
|
||||
prompt,
|
||||
issueId,
|
||||
},
|
||||
});
|
||||
|
||||
if (wakeResponse.status === "skipped") {
|
||||
session.onToolEnd?.("paperclip.run", true, {
|
||||
issueId,
|
||||
runStatus: "skipped",
|
||||
reason: "Paperclip coalesced this wakeup with a recent one (status=skipped).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runId = wakeResponse.id;
|
||||
if (!runId) {
|
||||
session.onToolEnd?.("paperclip.run", true, {
|
||||
issueId,
|
||||
reason: "Paperclip wakeup response missing run id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictError) {
|
||||
this.logger.warn(`Paperclip checkout conflict for issue ${issueId}; continuing: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip wakeup failed: ${reason}`);
|
||||
session.onToolEnd?.("paperclip.run", true, { issueId, reason });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Stage 3: stream run events --------------------------------------
|
||||
const stream = await this.streamRunEvents(session, runId);
|
||||
|
||||
// ---- Stage 4: collect final results ----------------------------------
|
||||
let issueStatus: string | undefined;
|
||||
let finalText = stream.text;
|
||||
if (issueId) {
|
||||
try {
|
||||
const issue = await getIssue(session.apiUrl, session.apiKey, issueId);
|
||||
issueStatus = asString(issue.status) ?? undefined;
|
||||
|
||||
// Comment fallback: if no streaming text was captured, use the latest
|
||||
// non-system comment as the visible answer.
|
||||
if (!finalText) {
|
||||
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
|
||||
const latest = pickLatestVisibleComment(comments);
|
||||
if (latest) finalText = latest;
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal — we still have whatever we streamed.
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip post-run fetch failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
await invokeHeartbeat(session.apiUrl, session.apiKey, session.agentId);
|
||||
if (finalText) session.onText?.(finalText);
|
||||
if (stream.thinking) session.onThinking?.(stream.thinking);
|
||||
|
||||
let issue = createdIssue;
|
||||
let status = pickIssueStatus(issue);
|
||||
let intervalMs = POLL_INITIAL_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (!TERMINAL_STATUSES.has(status) && Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await sleep(intervalMs);
|
||||
issue = await getIssue(session.apiUrl, session.apiKey, issueId);
|
||||
status = pickIssueStatus(issue);
|
||||
intervalMs = Math.min(intervalMs * 2, POLL_MAX_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
|
||||
const { text, thinking } = collectCommentText(comments);
|
||||
if (text) {
|
||||
session.onText?.(text);
|
||||
}
|
||||
if (thinking) {
|
||||
session.onThinking?.(thinking);
|
||||
}
|
||||
|
||||
session.onToolEnd?.("paperclip.issue", false, {
|
||||
const isError = stream.runStatus === "failed" || stream.runStatus === "timed_out";
|
||||
session.onToolEnd?.("paperclip.run", isError || stream.timedOutLocally, {
|
||||
runId,
|
||||
runStatus: stream.runStatus,
|
||||
issueId,
|
||||
status,
|
||||
issueStatus,
|
||||
timedOutLocally: stream.timedOutLocally,
|
||||
deepLink: issueId
|
||||
? `${session.apiUrl.replace(/\/$/, "")}/issues/${issueId}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,4 +285,108 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
async dispose(_session: PaperclipSession): Promise<void> {
|
||||
// no-op: Paperclip manages run/session lifecycle server-side
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private async createIssueForPrompt(
|
||||
session: PaperclipSession,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const created = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
|
||||
title: deriveIssueTitle(prompt),
|
||||
description: buildIssueDescription(session, prompt),
|
||||
status: "todo",
|
||||
assigneeAgentId: session.agentId,
|
||||
...(session.parentIssueId ? { parentId: session.parentIssueId } : {}),
|
||||
...(session.projectId ? { projectId: session.projectId } : {}),
|
||||
...(session.goalId ? { goalId: session.goalId } : {}),
|
||||
});
|
||||
return pickIssueId(created);
|
||||
}
|
||||
|
||||
private async streamRunEvents(
|
||||
session: PaperclipSession,
|
||||
runId: string,
|
||||
): Promise<{
|
||||
text: string;
|
||||
thinking: string;
|
||||
runStatus: string;
|
||||
timedOutLocally: boolean;
|
||||
}> {
|
||||
const startedAt = Date.now();
|
||||
let afterSeq = 0;
|
||||
let interval = session.pollIntervalMs;
|
||||
let runStatus = "running";
|
||||
let timedOutLocally = false;
|
||||
let textBuf = "";
|
||||
let thinkBuf = "";
|
||||
|
||||
while (true) {
|
||||
let events: RunEvent[] = [];
|
||||
try {
|
||||
events = await getRunEvents(
|
||||
session.apiUrl,
|
||||
session.apiKey,
|
||||
runId,
|
||||
afterSeq,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip getRunEvents failed: ${reason}`);
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
if (typeof ev.seq === "number" && ev.seq > afterSeq) afterSeq = ev.seq;
|
||||
const type = ev.type ?? "";
|
||||
const payload = ev.payload ?? {};
|
||||
if (type === "heartbeat.run.status") {
|
||||
const next = asString(payload.status);
|
||||
if (next) runStatus = next;
|
||||
} else if (type === "heartbeat.run.log") {
|
||||
const chunk = asString(payload.chunk) ?? "";
|
||||
if (!chunk) continue;
|
||||
if (payload.stream === "stdout") {
|
||||
textBuf += chunk;
|
||||
session.onText?.(chunk);
|
||||
} else if (payload.stream === "stderr") {
|
||||
this.logger.warn(`[paperclip:run:${runId}] ${chunk.trimEnd()}`);
|
||||
} else if (payload.stream === "system") {
|
||||
// system messages may carry reasoning/thinking-style content
|
||||
const message = asString(payload.message) ?? chunk;
|
||||
thinkBuf += (thinkBuf ? "\n" : "") + message;
|
||||
}
|
||||
}
|
||||
// Other event types (adapter.invoke, tool calls) are ignored for v1.
|
||||
}
|
||||
|
||||
if (TERMINAL_RUN_STATUSES.has(runStatus)) break;
|
||||
|
||||
if (Date.now() - startedAt > session.runTimeoutMs) {
|
||||
timedOutLocally = true;
|
||||
this.logger.warn(
|
||||
`Paperclip run ${runId} exceeded local runTimeoutMs=${session.runTimeoutMs}; abandoning poll. Run continues server-side.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(interval);
|
||||
interval = Math.min(interval * 2, session.pollIntervalMaxMs);
|
||||
}
|
||||
|
||||
return { text: textBuf, thinking: thinkBuf, runStatus, timedOutLocally };
|
||||
}
|
||||
}
|
||||
|
||||
function pickLatestVisibleComment(
|
||||
comments: Array<Record<string, unknown>>,
|
||||
): string | undefined {
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const c = comments[i];
|
||||
const body = asString(c.body)?.trim();
|
||||
if (body) return body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -21,14 +21,43 @@ export interface AgentRuntimeOptions {
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export type PaperclipMode = "issue-per-prompt" | "rolling-issue" | "wakeup-only";
|
||||
|
||||
/**
|
||||
* How the adapter authenticates to Paperclip.
|
||||
*
|
||||
* - `"api"` (default): caller provides apiUrl + apiKey explicitly. Suitable
|
||||
* for cloud installs or when you want to bypass the local CLI.
|
||||
* - `"cli"`: derive the apiUrl (and, if available, apiKey) from a local
|
||||
* `paperclipai` install. Reads `~/.paperclip/instances/default/config.json`
|
||||
* to get host:port; for local-trusted deployments no key is required.
|
||||
*/
|
||||
export type PaperclipTransport = "api" | "cli";
|
||||
|
||||
export interface PaperclipSession {
|
||||
apiUrl: string;
|
||||
apiKey: string | undefined;
|
||||
/** Resolved at session-create time (auto-derived from /agents/me if not configured). */
|
||||
agentId: string;
|
||||
/** Resolved at session-create time (auto-derived from /agents/me if not configured). */
|
||||
companyId: string;
|
||||
/** Logical Fusion session id; used as the basis for idempotency keys. */
|
||||
sessionId: string;
|
||||
systemPrompt: string;
|
||||
cwd: string;
|
||||
mode: PaperclipMode;
|
||||
/** Optional issue scoping passed at create time. */
|
||||
parentIssueId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
/** Set by the adapter on first prompt in `rolling-issue` mode; reused thereafter. */
|
||||
issueId?: string;
|
||||
/** Incremented per prompt. Combined with sessionId to form an idempotency key. */
|
||||
turnIndex: number;
|
||||
/** Hard cap for a single wakeup-run polling loop. */
|
||||
runTimeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
pollIntervalMaxMs: number;
|
||||
onText: ((text: string) => void) | undefined;
|
||||
onThinking: ((text: string) => void) | undefined;
|
||||
onToolStart: ((toolName: string, args?: unknown) => void) | undefined;
|
||||
@@ -55,6 +84,26 @@ export interface PaperclipRuntimeConfig {
|
||||
apiKey?: string;
|
||||
agentId?: string;
|
||||
companyId?: string;
|
||||
mode?: PaperclipMode;
|
||||
/**
|
||||
* Auth/discovery transport. Default `"api"`.
|
||||
* When `"cli"`, the adapter spawns `paperclipai` (or reads its config) to
|
||||
* derive apiUrl/apiKey at session-create time.
|
||||
*/
|
||||
transport?: PaperclipTransport;
|
||||
/** Path to the `paperclipai` binary when transport=cli. Default `"paperclipai"`. */
|
||||
cliBinaryPath?: string;
|
||||
/**
|
||||
* Path to the paperclipai instance config file. Default
|
||||
* `~/.paperclip/instances/default/config.json`.
|
||||
*/
|
||||
cliConfigPath?: string;
|
||||
parentIssueId?: string;
|
||||
projectId?: string;
|
||||
goalId?: string;
|
||||
runTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
pollIntervalMaxMs?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeLogger {
|
||||
|
||||
Reference in New Issue
Block a user