merge master into pap-1167-app-ui-bundle

This commit is contained in:
dotta
2026-04-07 07:10:14 -05:00
42 changed files with 15528 additions and 428 deletions
+4
View File
@@ -220,6 +220,7 @@ describe("renderCompanyImportPreview", () => {
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],
@@ -250,6 +251,7 @@ describe("renderCompanyImportPreview", () => {
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
@@ -265,6 +267,7 @@ describe("renderCompanyImportPreview", () => {
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
@@ -432,6 +435,7 @@ describe("import selection catalog", () => {
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],
+5
View File
@@ -184,6 +184,11 @@ Invariant: at least one root `company` level goal per company.
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
- `env` jsonb null (same secret-aware env binding format used by agent config)
Invariant:
- project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected
## 7.6 `issues` (core task entity)
+362
View File
@@ -0,0 +1,362 @@
# 2026-04-06 Smart Model Routing
Status: Proposed
Date: 2026-04-06
Audience: Product and engineering
Related:
- `doc/SPEC-implementation.md`
- `doc/PRODUCT.md`
- `doc/plans/2026-03-14-adapter-skill-sync-rollout.md`
## 1. Purpose
This document defines a V1 plan for "smart model routing" in Paperclip.
The goal is not to build a generic cross-provider router in the server. The goal is:
- let supported adapters use a cheaper model for lightweight heartbeat orchestration work
- keep the main task execution on the adapter's normal primary model
- preserve Paperclip's existing task, session, and audit invariants
- report cost and model usage truthfully when more than one model participates in a single heartbeat
The motivating use case is a local coding adapter where a cheap model can handle the first fast pass:
- read the wake context
- orient to the task and workspace
- leave an immediate progress comment when appropriate
- perform bounded lightweight triage
Then the primary model does the substantive work.
## 2. Hermes Findings
Hermes does have a real "smart model routing" feature, but it is narrower than the name suggests.
Observed behavior:
- `agent/smart_model_routing.py` implements a conservative classifier for "simple" turns
- the cheap path only triggers for short, single-line, non-code, non-URL, non-tool-heavy messages
- complexity is detected with hardcoded thresholds plus a keyword denylist like `debug`, `implement`, `test`, `plan`, `tool`, `docker`, and similar terms
- if the cheap route cannot be resolved, Hermes silently falls back to the primary model
Important architectural detail:
- Hermes applies this routing before constructing the agent for that turn
- the route is resolved in `cron/scheduler.py` and passed into agent creation as the active provider/model/runtime
More useful than the routing heuristic itself is Hermes' broader model-slot design:
- main conversational model
- fallback model for failover
- auxiliary model slots for side tasks like compression and classification
That separation is a better fit for Paperclip than copying Hermes' exact keyword heuristic.
## 3. Current Paperclip State
Paperclip already has the right execution shape for adapter-specific routing, but it currently assumes one model per heartbeat run.
Current implementation facts:
- `server/src/services/heartbeat.ts` builds rich run context, including `paperclipWake`, workspace metadata, and session handoff context
- each adapter receives a single resolved `config` object and executes once
- built-in local adapters read one `config.model` and pass it directly to the underlying CLI
- UI config today exposes one main `model` field plus adapter-specific thinking-effort controls
- cost accounting currently records one provider/model tuple per run via `AdapterExecutionResult`
What this means:
- there is no shared routing layer in the server today
- model choice already lives at the adapter boundary, which is good
- multi-model execution in a single heartbeat needs explicit contract work or cost reporting will become misleading
## 4. Product Decision
Paperclip should implement smart model routing as an adapter-local, opt-in execution pattern.
V1 decision:
1. Do not add a global server-side router that tries to understand every adapter.
2. Do not copy Hermes' prompt-keyword classifier as Paperclip's default routing policy.
3. Add an adapter-specific "cheap preflight" phase for supported adapters.
4. Keep the primary model as the canonical work model.
5. Persist only the primary session unless an adapter can prove that cross-model session resume is safe.
Rationale:
- Paperclip heartbeats are structured, issue-scoped, and already include wake metadata
- routing by execution phase is more reliable than routing by free-text prompt complexity
- session semantics differ by adapter, so resume behavior must stay adapter-owned
## 5. Proposed V1 Behavior
## 5.1 Config shape
Supported adapters should add an optional routing block to `adapterConfig`.
Proposed shape:
```ts
smartModelRouting?: {
enabled: boolean;
cheapModel: string;
cheapThinkingEffort?: string;
maxPreflightTurns?: number;
allowInitialProgressComment?: boolean;
}
```
Notes:
- keep existing `model` as the primary model
- `cheapModel` is adapter-specific, not global
- adapters that cannot safely support this block simply ignore it
For adapters with provider-specific model fields later, the shape can expand to include provider/base-url overrides. V1 should start simple.
## 5.2 Routing policy
Supported adapters should run cheap preflight only when all are true:
- `smartModelRouting.enabled` is true
- `cheapModel` is configured
- the run is issue-scoped
- the adapter is starting a fresh session, not resuming a persisted one
- the run is expected to do real task work rather than just resume an existing thread
Supported adapters should skip cheap preflight when any are true:
- a persisted task session already exists
- the adapter cannot safely isolate preflight from the primary session
- the issue or wake type implies the task is already mid-flight and continuity matters more than first-response speed
This is intentionally phase-based, not text-heuristic-based.
## 5.3 Cheap preflight responsibilities
The cheap phase should be narrow and bounded.
Allowed responsibilities:
- ingest wake context and issue summary
- inspect the workspace at a shallow level
- leave a short "starting investigation" style comment when appropriate
- collect a compact handoff summary for the primary phase
Not allowed in V1:
- long tool loops
- risky file mutations
- being the canonical persisted task session
- deciding final completion without either explicit adapter support or a trivial success case
Implementation detail:
- the adapter should inject an explicit preflight prompt telling the model this is a bounded orchestration pass
- preflight should use a very small turn budget, for example 1-2 turns
## 5.4 Primary execution responsibilities
After preflight, the adapter launches the normal primary execution using the existing prompt and primary model.
The primary phase should receive:
- the normal Paperclip prompt
- any preflight-generated handoff summary
- normal workspace and wake context
The primary phase remains the source of truth for:
- persisted session state
- final task completion
- most file changes
- most cost
## 6. Required Contract Changes
The current `AdapterExecutionResult` is too narrow for truthful multi-model accounting.
Add an optional segmented execution report, for example:
```ts
executionSegments?: Array<{
phase: "cheap_preflight" | "primary";
provider?: string | null;
biller?: string | null;
model?: string | null;
billingType?: AdapterBillingType | null;
usage?: UsageSummary;
costUsd?: number | null;
summary?: string | null;
}>
```
V1 server behavior:
- if `executionSegments` is absent, keep current single-result behavior unchanged
- if present, write one `cost_events` row per segment that has cost or token usage
- store the segment array in run usage/result metadata for later UI inspection
- keep the existing top-level `provider` / `model` fields as a summary, preferably the primary phase when present
This avoids breaking existing adapters while giving routed adapters truthful reporting.
## 7. Adapter Rollout Plan
## 7.1 Phase 1: contract and server plumbing
Work:
1. Extend adapter result types with segmented execution metadata.
2. Update heartbeat cost recording to emit multiple cost events when segments are present.
3. Include segment summaries in run metadata for transcript/debug views.
Success criteria:
- existing adapters behave exactly as before
- a routed adapter can report cheap plus primary usage without collapsing them into one fake model
## 7.2 Phase 2: `codex_local`
Why first:
- Codex already has rich prompt/handoff handling
- the adapter already injects Paperclip skills and workspace metadata cleanly
- the current implementation already distinguishes bootstrap, wake delta, and handoff prompt sections
Implementation work:
1. Add config support for `smartModelRouting`.
2. Add a cheap-preflight prompt builder.
3. Run cheap preflight only on fresh sessions.
4. Pass a compact preflight handoff note into the primary prompt.
5. Report segmented usage and model metadata.
Important guardrail:
- do not resume the cheap-model session as the primary session in V1
## 7.3 Phase 3: `claude_local`
Implementation work is similar, but the session model-switch risk is even less attractive.
Same rule:
- cheap preflight is ephemeral
- primary Claude session remains canonical
## 7.4 Phase 4: other adapters
Candidates:
- `cursor`
- `gemini_local`
- `opencode_local`
- external plugin adapters through `createServerAdapter()`
These should come later because each runtime has different session and model-switch semantics.
## 8. UI and Config Changes
For supported built-in adapters, the agent config UI should expose:
- `model` as the primary model
- `smart model routing` toggle
- `cheap model`
- optional cheap thinking effort
- optional `allow initial progress comment` toggle
The run detail UI should also show when routing occurred, for example:
- cheap preflight model
- primary model
- token/cost split
This matters because Paperclip's board UI is supposed to make cost and behavior legible.
## 9. Why Not Copy Hermes Exactly
Hermes' cheap-route heuristic is useful precedent, but Paperclip should not start there.
Reasons:
- Hermes is optimizing free-form conversational turns
- Paperclip agents run structured, issue-scoped heartbeats with explicit task and workspace context
- Paperclip already knows whether a run is fresh vs resumed, issue-scoped vs approval follow-up, and what workspace/session exists
- those execution facts are stronger routing signals than prompt keyword matching
If Paperclip later wants a cheap-only completion path for trivial runs, that can be a second-stage feature built on observed run data, not the first implementation.
## 10. Risks
## 10.1 Duplicate or noisy comments
If the cheap phase posts an update and the primary phase posts another near-identical update, the issue thread gets worse.
Mitigation:
- keep cheap comments optional
- make the preflight prompt explicitly avoid repeating status if a useful comment was already posted
## 10.2 Misleading cost reporting
If we only record the primary model, the board loses visibility into the routing cost tradeoff.
Mitigation:
- add segmented execution reporting before shipping adapter behavior
## 10.3 Session corruption
Cross-model session reuse may fail or degrade context quality.
Mitigation:
- V1 does not persist or resume cheap preflight sessions
## 10.4 Cheap model overreach
A cheap model with full tools and permissions may do too much low-quality work.
Mitigation:
- hard cap preflight turns
- use an explicit orchestration-only prompt
- start with supported adapters where we can test the behavior well
## 11. Verification Plan
Required tests:
- adapter unit tests for route eligibility
- adapter unit tests for "fresh session -> cheap preflight + primary"
- adapter unit tests for "resumed session -> primary only"
- heartbeat tests for segmented cost-event creation
- UI tests for config save/load of cheap-model fields
Manual checks:
- create a fresh issue for a routed Codex or Claude agent
- verify the run metadata shows both phases
- verify only the primary session is persisted
- verify cost rows reflect both models
- verify the issue thread does not get duplicate kickoff comments
## 12. Recommended Sequence
1. Add segmented execution reporting to the adapter/server contract.
2. Implement `codex_local` cheap preflight.
3. Validate cost visibility and transcript UX.
4. Implement `claude_local` cheap preflight.
5. Decide later whether any adapters need Hermes-style text heuristics in addition to phase-based routing.
## 13. Recommendation
Paperclip should ship smart model routing as:
- adapter-specific
- opt-in
- phase-based
- session-safe
- cost-truthful
The right V1 is not "choose the cheapest model for simple prompts." The right V1 is "use a cheap model for bounded orchestration work on fresh runs, then hand off to the primary model for the real task."
+45
View File
@@ -176,4 +176,49 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
},
60_000,
);
it(
"restores statements incrementally when backup comments precede the first breakpoint",
async () => {
const restoreConnectionString = await createTempDatabase();
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
const backupDir = createTempDir("paperclip-db-restore-manual-");
const backupFile = path.join(backupDir, "manual.sql");
try {
await fs.promises.writeFile(
backupFile,
[
"-- Paperclip database backup",
"-- Created: 2026-04-06T00:00:00.000Z",
"",
"BEGIN;",
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
"CREATE TABLE public.restore_stream_test (id integer primary key, payload text not null);",
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
"INSERT INTO public.restore_stream_test (id, payload)",
"VALUES (1, 'hello');",
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
"COMMIT;",
"-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900",
].join("\n"),
"utf8",
);
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile,
});
const rows = await restoreSql.unsafe<{ payload: string }[]>(`
SELECT payload
FROM public.restore_stream_test
`);
expect(rows).toEqual([{ payload: "hello" }]);
} finally {
await restoreSql.end();
}
},
20_000,
);
});
+39 -9
View File
@@ -1,6 +1,6 @@
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { basename, resolve } from "node:path";
import { createInterface } from "node:readline";
import postgres from "postgres";
export type RunDatabaseBackupOptions = {
@@ -147,6 +147,42 @@ function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
const stream = createReadStream(backupFile, { encoding: "utf8" });
const reader = createInterface({
input: stream,
crlfDelay: Infinity,
});
let statementLines: string[] = [];
const flushStatement = () => {
const statement = statementLines.join("\n").trim();
statementLines = [];
return statement;
};
try {
for await (const line of reader) {
if (line === STATEMENT_BREAKPOINT) {
const statement = flushStatement();
if (statement.length > 0) {
yield statement;
}
continue;
}
statementLines.push(line);
}
const trailingStatement = flushStatement();
if (trailingStatement.length > 0) {
yield trailingStatement;
}
} finally {
reader.close();
stream.destroy();
}
}
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
const stream = createWriteStream(filePath, { encoding: "utf8" });
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
@@ -650,13 +686,7 @@ export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promi
try {
await sql`SELECT 1`;
const contents = await readFile(opts.backupFile, "utf8");
const statements = contents
.split(STATEMENT_BREAKPOINT)
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
for await (const statement of readRestoreStatements(opts.backupFile)) {
await sql.unsafe(statement).execute();
}
} catch (error) {
+66
View File
@@ -401,4 +401,70 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays migration 0050 safely when projects.env already exists",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const stiffLuckmanHash = await migrationHash("0050_stiff_luckman.sql");
await sql.unsafe(
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${stiffLuckmanHash}'`,
);
const columns = await sql.unsafe<{ column_name: string }[]>(
`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'projects'
AND column_name = 'env'
`,
);
expect(columns).toHaveLength(1);
} finally {
await sql.end();
}
const pendingState = await inspectMigrations(connectionString);
expect(pendingState).toMatchObject({
status: "needsMigrations",
pendingMigrations: ["0050_stiff_luckman.sql"],
reason: "pending-migrations",
});
await applyPendingMigrations(connectionString);
const finalState = await inspectMigrations(connectionString);
expect(finalState.status).toBe("upToDate");
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>(
`
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'projects'
AND column_name = 'env'
`,
);
expect(columns).toEqual([
expect.objectContaining({
column_name: "env",
is_nullable: "YES",
data_type: "jsonb",
}),
]);
} finally {
await verifySql.end();
}
},
20_000,
);
});
@@ -0,0 +1 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "env" jsonb;
File diff suppressed because it is too large Load Diff
@@ -355,6 +355,13 @@
{
"idx": 50,
"version": "7",
"when": 1775487782768,
"tag": "0050_stiff_luckman",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1775524651831,
"tag": "0051_young_korg",
"breakpoints": true
+2
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
@@ -15,6 +16,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
env: jsonb("env").$type<AgentEnvConfig>(),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
@@ -1,3 +1,6 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityInclude {
company: boolean;
agents: boolean;
@@ -10,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
key: string;
description: string | null;
agentSlug: string | null;
projectSlug: string | null;
kind: "secret" | "plain";
requirement: "required" | "optional";
defaultValue: string | null;
@@ -52,13 +56,12 @@ export interface CompanyPortabilityProjectManifestEntry {
targetDate: string | null;
color: string | null;
status: string | null;
env: AgentEnvConfig | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
metadata: Record<string, unknown> | null;
}
import type { RoutineVariable } from "./routine.js";
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
key: string;
name: string;
+2
View File
@@ -1,4 +1,5 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { AgentEnvConfig } from "./secrets.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
@@ -65,6 +66,7 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
env: AgentEnvConfig | null;
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
@@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
key: z.string().min(1),
description: z.string().nullable(),
agentSlug: z.string().min(1).nullable(),
projectSlug: z.string().min(1).nullable(),
kind: z.enum(["secret", "plain"]),
requirement: z.enum(["required", "optional"]),
defaultValue: z.string().nullable(),
@@ -1,5 +1,6 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
import { envConfigSchema } from "./secret.js";
const executionWorkspaceStrategySchema = z
.object({
@@ -102,6 +103,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
env: envConfigSchema.optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
};
+93
View File
@@ -0,0 +1,93 @@
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
function normalizeByteLimit(maxBytes) {
return Math.max(1, Math.trunc(maxBytes));
}
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
const limit = normalizeByteLimit(maxBytes);
const chunks = [];
let bufferedBytes = 0;
let totalBytes = 0;
let truncated = false;
return {
append(chunk) {
if (chunk === null || chunk === undefined) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (buffer.length === 0) return;
chunks.push(buffer);
bufferedBytes += buffer.length;
totalBytes += buffer.length;
while (bufferedBytes > limit && chunks.length > 0) {
const overflow = bufferedBytes - limit;
const head = chunks[0];
if (head.length <= overflow) {
chunks.shift();
bufferedBytes -= head.length;
truncated = true;
continue;
}
chunks[0] = head.subarray(overflow);
bufferedBytes -= overflow;
truncated = true;
}
},
finish() {
const body = Buffer.concat(chunks).toString("utf8");
if (!truncated) {
return {
text: body,
truncated,
totalBytes,
};
}
return {
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
truncated,
totalBytes,
};
},
};
}
export async function parseJsonResponseWithLimit(response, maxBytes = DEFAULT_JSON_RESPONSE_BYTES) {
const limit = normalizeByteLimit(maxBytes);
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
if (Number.isFinite(contentLength) && contentLength > limit) {
throw new Error(`Response exceeds ${limit} bytes`);
}
if (!response.body) {
return JSON.parse("");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limit) {
await reader.cancel("response too large");
throw new Error(`Response exceeds ${limit} bytes`);
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} finally {
reader.releaseLock();
}
return JSON.parse(text);
}
+102
View File
@@ -0,0 +1,102 @@
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
export type CapturedOutput = {
text: string;
truncated: boolean;
totalBytes: number;
};
function normalizeByteLimit(maxBytes: number) {
return Math.max(1, Math.trunc(maxBytes));
}
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
const limit = normalizeByteLimit(maxBytes);
const chunks: Buffer[] = [];
let bufferedBytes = 0;
let totalBytes = 0;
let truncated = false;
return {
append(chunk: Buffer | string | null | undefined) {
if (chunk === null || chunk === undefined) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (buffer.length === 0) return;
chunks.push(buffer);
bufferedBytes += buffer.length;
totalBytes += buffer.length;
while (bufferedBytes > limit && chunks.length > 0) {
const overflow = bufferedBytes - limit;
const head = chunks[0]!;
if (head.length <= overflow) {
chunks.shift();
bufferedBytes -= head.length;
truncated = true;
continue;
}
chunks[0] = head.subarray(overflow);
bufferedBytes -= overflow;
truncated = true;
}
},
finish(): CapturedOutput {
const body = Buffer.concat(chunks).toString("utf8");
if (!truncated) {
return {
text: body,
truncated,
totalBytes,
};
}
return {
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${body}`,
truncated,
totalBytes,
};
},
};
}
export async function parseJsonResponseWithLimit<T>(
response: Response,
maxBytes = DEFAULT_JSON_RESPONSE_BYTES,
): Promise<T> {
const limit = normalizeByteLimit(maxBytes);
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
if (Number.isFinite(contentLength) && contentLength > limit) {
throw new Error(`Response exceeds ${limit} bytes`);
}
if (!response.body) {
throw new Error("Response has no body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limit) {
await reader.cancel("response too large");
throw new Error(`Response exceeds ${limit} bytes`);
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} finally {
reader.releaseLock();
}
return JSON.parse(text) as T;
}
+11 -7
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { fileURLToPath } from "node:url";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
const mode = process.argv[2] === "watch" ? "watch" : "dev";
@@ -250,30 +251,33 @@ async function runPnpm(args, options = {}) {
const spawned = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env,
cwd: options.cwd,
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
const stdoutBuffer = createCapturedOutputBuffer();
const stderrBuffer = createCapturedOutputBuffer();
if (spawned.stdout) {
spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
stdoutBuffer.append(chunk);
});
}
if (spawned.stderr) {
spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
stderrBuffer.append(chunk);
});
}
spawned.on("error", reject);
spawned.on("exit", (code, signal) => {
const stdout = stdoutBuffer.finish();
const stderr = stderrBuffer.finish();
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
stdout: stdout.text,
stderr: stderr.text,
});
});
});
@@ -426,7 +430,7 @@ async function getDevHealthPayload() {
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await response.json();
return await parseJsonResponseWithLimit(response);
}
async function waitForChildExit() {
+10 -7
View File
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
import {
@@ -315,27 +316,29 @@ async function runPnpm(args: string[], options: {
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
const stdoutBuffer = createCapturedOutputBuffer();
const stderrBuffer = createCapturedOutputBuffer();
if (spawned.stdout) {
spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
stdoutBuffer.append(chunk);
});
}
if (spawned.stderr) {
spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
stderrBuffer.append(chunk);
});
}
spawned.on("error", reject);
spawned.on("exit", (code, signal) => {
const stdout = stdoutBuffer.finish();
const stderr = stderrBuffer.finish();
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
stdout: stdout.text,
stderr: stderr.text,
});
});
});
@@ -484,7 +487,7 @@ async function getDevHealthPayload() {
if (!response.ok) {
throw new Error(`Health request failed (${response.status})`);
}
return await response.json();
return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response);
}
async function waitForChildExit() {
+22 -41
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env -S node --import tsx
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import path from "node:path";
import { repoRoot } from "./dev-service-profile.ts";
type WorkspaceLinkMismatch = {
workspaceDir: string;
packageName: string;
expectedPath: string;
actualPath: string | null;
@@ -44,11 +45,11 @@ function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json"));
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
const dependencies = {
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
...(packageJson.dependencies as Record<string, unknown> | undefined),
...(packageJson.devDependencies as Record<string, unknown> | undefined),
};
const mismatches: WorkspaceLinkMismatch[] = [];
@@ -58,11 +59,12 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
const expectedPath = workspacePackagePaths.get(packageName);
if (!expectedPath) continue;
const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/"));
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
if (actualPath === path.resolve(expectedPath)) continue;
mismatches.push({
workspaceDir,
packageName,
expectedPath: path.resolve(expectedPath),
actualPath,
@@ -72,53 +74,32 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] {
return mismatches;
}
function runCommand(command: string, args: string[], cwd: string) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`,
),
);
});
});
}
async function ensureServerWorkspaceLinksCurrent() {
const mismatches = findServerWorkspaceLinkMismatches();
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
if (mismatches.length === 0) return;
console.log("[paperclip] detected stale workspace package links for server; relinking dependencies...");
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
for (const mismatch of mismatches) {
console.log(
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
);
}
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
await runCommand(
pnpmBin,
["install", "--force", "--config.confirmModulesPurge=false"],
repoRoot,
);
for (const mismatch of mismatches) {
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
await fs.mkdir(path.dirname(linkPath), { recursive: true });
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(mismatch.expectedPath, linkPath);
}
const remainingMismatches = findServerWorkspaceLinkMismatches();
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
if (remainingMismatches.length === 0) return;
throw new Error(
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
);
}
await ensureServerWorkspaceLinksCurrent();
for (const workspaceDir of ["server", "ui"]) {
await ensureWorkspaceLinksCurrent(workspaceDir);
}
+3 -1
View File
@@ -361,7 +361,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
done < <(list_base_node_modules_paths)
if [[ "$needs_install" -eq 1 ]]; then
backup_suffix=".paperclip-backup-$BASHPID"
backup_suffix=".paperclip-backup-${BASHPID:-$$}"
moved_symlink_paths=()
while IFS= read -r relative_path; do
@@ -377,6 +377,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
restore_moved_symlinks() {
local relative_path target_path backup_path
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
for relative_path in "${moved_symlink_paths[@]}"; do
target_path="$worktree_cwd/$relative_path"
backup_path="${target_path}${backup_suffix}"
@@ -388,6 +389,7 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t
cleanup_moved_symlinks() {
local relative_path target_path backup_path
[[ ${#moved_symlink_paths[@]} -gt 0 ]] || return 0
for relative_path in "${moved_symlink_paths[@]}"; do
target_path="$worktree_cwd/$relative_path"
backup_path="${target_path}${backup_suffix}"
@@ -1149,6 +1149,7 @@ describe("company portability", () => {
key: "ANTHROPIC_API_KEY",
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
@@ -1158,6 +1159,7 @@ describe("company portability", () => {
key: "GH_TOKEN",
description: "Provide GH_TOKEN for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
@@ -1166,6 +1168,128 @@ describe("company portability", () => {
]);
});
it("exports project env as portable inputs without concrete values", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
DOCS_MODE: {
type: "plain",
value: "strict",
},
GITHUB_TOKEN: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("OPENAI_API_KEY:");
expect(extension).toContain("DOCS_MODE:");
expect(extension).toContain("GITHUB_TOKEN:");
expect(extension).not.toContain("sk-project-secret");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
expect(extension).toContain('default: "strict"');
expect(extension).toContain('kind: "secret"');
expect(extension).toContain('kind: "plain"');
});
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on project launch",
agentSlug: null,
projectSlug: "launch",
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
});
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
const portability = companyPortabilityService({} as any);
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "../../../scripts/dev-runner-output.mjs";
describe("createCapturedOutputBuffer", () => {
it("keeps small output unchanged", () => {
const capture = createCapturedOutputBuffer(32);
capture.append("hello");
capture.append(" world");
expect(capture.finish()).toEqual({
text: "hello world",
totalBytes: 11,
truncated: false,
});
});
it("retains only the bounded tail when output grows large", () => {
const capture = createCapturedOutputBuffer(8);
capture.append("abcd");
capture.append(Buffer.from("efgh"));
capture.append("ijkl");
const result = capture.finish();
expect(result.truncated).toBe(true);
expect(result.totalBytes).toBe(12);
expect(result.text).toContain("total 12 bytes");
expect(result.text.endsWith("efghijkl")).toBe(true);
});
it("parses bounded JSON responses", async () => {
const response = new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
});
await expect(parseJsonResponseWithLimit<{ ok: boolean }>(response, 64)).resolves.toEqual({ ok: true });
});
it("rejects oversized JSON responses before parsing them", async () => {
const response = new Response(JSON.stringify({ payload: "x".repeat(128) }), {
headers: { "content-type": "application/json" },
});
await expect(parseJsonResponseWithLimit(response, 32)).rejects.toThrow("Response exceeds 32 bytes");
});
});
@@ -63,4 +63,14 @@ describe("dev server status helpers", () => {
waitingForIdle: true,
});
});
it("ignores oversized persisted status files", () => {
const filePath = createTempStatusFile({
dirty: true,
changedPathsSample: ["x".repeat(70 * 1024)],
pendingMigrations: [],
});
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {
env: {
SHARED_KEY: "agent",
AGENT_ONLY: "agent-only",
},
other: "value",
},
secretKeys: new Set(["AGENT_SECRET"]),
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {
SHARED_KEY: "project",
PROJECT_ONLY: "project-only",
},
secretKeys: new Set(["PROJECT_SECRET"]),
});
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { SHARED_KEY: "agent" } },
projectEnv: { SHARED_KEY: "project" },
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(result.resolvedConfig).toMatchObject({
other: "value",
env: {
SHARED_KEY: "project",
AGENT_ONLY: "agent-only",
PROJECT_ONLY: "project-only",
},
});
expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]);
});
it("skips project env resolution when the project has no bindings", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: { AGENT_ONLY: "agent-only" } },
secretKeys: new Set<string>(),
});
const resolveEnvBindings = vi.fn();
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
projectEnv: null,
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
@@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
@@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
goalService: () => mockGoalService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({
id: "project-1",
companyId: "company-1",
@@ -0,0 +1,188 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockProjectService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
createWorkspace: vi.fn(),
listWorkspaces: vi.fn(),
updateWorkspace: vi.fn(),
removeWorkspace: vi.fn(),
remove: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackProjectCreated: mockTrackProjectCreated,
};
});
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
vi.mock("../services/workspace-runtime.js", () => ({
startRuntimeServicesForWorkspaceControl: vi.fn(),
stopRuntimeServicesForProjectWorkspace: vi.fn(),
}));
async function createApp() {
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "board-user",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", projectRoutes({} as any));
app.use(errorHandler);
return app;
}
function buildProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "backlog",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project",
effectiveLocalFolder: "/tmp/project",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("project env routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.createWorkspace.mockResolvedValue(null);
mockProjectService.listWorkspaces.mockResolvedValue([]);
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
});
it("normalizes env bindings on create and logs only env keys", async () => {
const normalizedEnv = {
API_KEY: {
type: "secret_ref",
secretId: "11111111-1111-4111-8111-111111111111",
version: "latest",
},
};
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
mockProjectService.create.mockResolvedValue(buildProject({ env: normalizedEnv }));
const app = await createApp();
const res = await request(app)
.post("/api/companies/company-1/projects")
.send({
name: "Project",
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockSecretService.normalizeEnvBindingsForPersistence).toHaveBeenCalledWith(
"company-1",
normalizedEnv,
expect.objectContaining({ fieldPath: "env" }),
);
expect(mockProjectService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
details: expect.objectContaining({
envKeys: ["API_KEY"],
}),
}),
);
});
it("normalizes env bindings on update and avoids logging raw values", async () => {
const normalizedEnv = {
PLAIN_KEY: { type: "plain", value: "top-secret" },
};
mockSecretService.normalizeEnvBindingsForPersistence.mockResolvedValue(normalizedEnv);
mockProjectService.getById.mockResolvedValue(buildProject());
mockProjectService.update.mockResolvedValue(buildProject({ env: normalizedEnv }));
const app = await createApp();
const res = await request(app)
.patch("/api/projects/project-1")
.send({
env: normalizedEnv,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockProjectService.update).toHaveBeenCalledWith(
"project-1",
expect.objectContaining({ env: normalizedEnv }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
details: {
changedKeys: ["env"],
envKeys: ["PLAIN_KEY"],
},
}),
);
});
});
+285 -1
View File
@@ -20,6 +20,7 @@ import {
import { eq } from "drizzle-orm";
import {
cleanupExecutionWorkspaceArtifacts,
ensureServerWorkspaceLinksCurrent,
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
reconcilePersistedRuntimeServicesOnStartup,
@@ -187,6 +188,75 @@ describe("sanitizeRuntimeServiceBaseEnv", () => {
});
});
describe("ensureServerWorkspaceLinksCurrent", () => {
it("relinks stale server workspace dependencies inside the current repo root", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-"));
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
const stalePackageDir = path.join(staleRoot, "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(stalePackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.writeFile(
path.join(stalePackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir));
});
it("skips relinking when server workspace dependencies already point at the repo", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
});
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
@@ -413,6 +483,96 @@ describe("realizeExecutionWorkspace", () => {
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
it("uses the latest repo-managed provision script when reusing an existing worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf 'v1\\n' > .paperclip-provision-version",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add initial provision script"]);
const initial = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-449",
title: "Reuse latest provision script",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v1\n");
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf 'v2\\n' > .paperclip-provision-version",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Update provision script"]);
await expect(fs.readFile(path.join(initial.cwd, "scripts", "provision.sh"), "utf8")).resolves.toContain("v1");
const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-449",
title: "Reuse latest provision script",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n");
});
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
const repoRoot = await createTempRepo();
const previousCwd = process.cwd();
@@ -663,9 +823,82 @@ describe("realizeExecutionWorkspace", () => {
await fs.realpath(path.join(repoRoot, "packages", "shared")),
);
},
15_000,
30_000,
);
it("provisions successfully when install is needed but there are no symlinked node_modules to move", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(repoRoot, "pnpm-lock.yaml"),
[
"lockfileVersion: '9.0'",
"",
"settings:",
" autoInstallPeers: true",
" excludeLinksFromLockfile: false",
"",
"importers:",
" .: {}",
"",
].join("\n"),
"utf8",
);
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
await fs.mkdir(path.join(repoRoot, "node_modules"), { recursive: true });
await fs.writeFile(path.join(repoRoot, "node_modules", ".keep"), "", "utf8");
await runGit(repoRoot, ["add", "package.json", "pnpm-lock.yaml", "scripts/provision-worktree.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add minimal provision fixture"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-552",
title: "Install without moved symlinks",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip", "config.json"), "utf8")).resolves.toContain(
"\"database\"",
);
}, 30_000);
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
@@ -724,6 +957,57 @@ describe("realizeExecutionWorkspace", () => {
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
});
it("truncates oversized provision command output before storing it in memory", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "noisy.js"),
'process.stdout.write("x".repeat(400000));\n',
"utf8",
);
await runGit(repoRoot, ["add", "scripts/noisy.js"]);
await runGit(repoRoot, ["commit", "-m", "Add noisy provision script"]);
await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "node ./scripts/noisy.js",
},
},
issue: {
id: "issue-1",
identifier: "PAP-1142",
title: "Limit noisy provision output",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
const provisionOperation = operations.find((operation) => operation.phase === "workspace_provision");
expect(provisionOperation?.result.metadata).toMatchObject({
stdoutTruncated: true,
stderrTruncated: false,
});
expect(provisionOperation?.result.stdout).toContain("[output truncated to last");
expect(provisionOperation?.result.stdout?.length ?? 0).toBeLessThan(300000);
});
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
const repoRoot = await createTempRepo();
const branchName = "PAP-450-recreate-missing-worktree";
+6 -1
View File
@@ -1,4 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
export type PersistedDevServerStatus = {
dirty: boolean;
@@ -44,6 +46,9 @@ export function readPersistedDevServerStatus(
if (!filePath || !existsSync(filePath)) return null;
try {
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) {
return null;
}
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
+24 -2
View File
@@ -9,7 +9,7 @@ import {
} from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
@@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
@@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
if (projectData.env !== undefined) {
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
companyId,
projectData.env,
{ strictMode: strictSecretsMode, fieldPath: "env" },
);
}
const project = await svc.create(companyId, projectData);
let createdWorkspaceId: string | null = null;
if (workspace) {
@@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
details: {
name: project.name,
workspaceId: createdWorkspaceId,
envKeys: project.env ? Object.keys(project.env).sort() : [],
},
});
const telemetryClient = getTelemetryClient();
@@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
if (body.env !== undefined) {
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
strictMode: strictSecretsMode,
fieldPath: "env",
});
}
const project = await svc.update(id, body);
if (!project) {
res.status(404).json({ error: "Project not found" });
@@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
action: "project.updated",
entityType: "project",
entityId: project.id,
details: req.body,
details: {
changedKeys: Object.keys(req.body).sort(),
envKeys:
body.env && typeof body.env === "object" && !Array.isArray(body.env)
? Object.keys(body.env as Record<string, unknown>).sort()
: undefined,
},
});
res.json(project);
+159 -63
View File
@@ -27,6 +27,7 @@ import type {
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
} from "@paperclipai/shared";
import {
@@ -39,6 +40,7 @@ import {
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@@ -387,6 +389,88 @@ function isSensitiveEnvKey(key: string) {
);
}
function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
const parsed = envConfigSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
agentSlug: string | null;
projectSlug: string | null;
},
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
for (const [key, binding] of Object.entries(env)) {
if (key.toUpperCase() === "PATH") {
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
continue;
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
continue;
}
if (isPlainRecord(binding) && binding.type === "plain") {
const defaultValue = asString(binding.value);
const isSensitive = isSensitiveEnvKey(key);
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: isSensitive ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitive ? "" : defaultValue ?? "",
portability,
});
continue;
}
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
portability,
});
}
}
return inputs;
}
type ResolvedSource = {
manifest: CompanyPortabilityManifest;
files: Record<string, CompanyPortabilityFileEntry>;
@@ -419,6 +503,7 @@ type ProjectLike = {
targetDate: string | null;
color: string | null;
status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces?: Array<{
id: string;
@@ -1528,68 +1613,33 @@ function extractPortableEnvInputs(
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
warningPrefix: `Agent ${agentSlug}`,
agentSlug,
projectSlug: null,
},
envValue,
warnings,
);
}
for (const [key, binding] of Object.entries(env)) {
if (key.toUpperCase() === "PATH") {
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
continue;
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
inputs.push({
key,
description: `Provide ${key} for agent ${agentSlug}`,
agentSlug,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
continue;
}
if (isPlainRecord(binding) && binding.type === "plain") {
const defaultValue = asString(binding.value);
const isSensitive = isSensitiveEnvKey(key);
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on agent ${agentSlug}`,
agentSlug,
kind: isSensitive ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitive ? "" : defaultValue ?? "",
portability,
});
continue;
}
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on agent ${agentSlug}`,
agentSlug,
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
requirement: "optional",
defaultValue: binding,
portability,
});
}
}
return inputs;
function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
warningPrefix: `Project ${projectSlug}`,
agentSlug: null,
projectSlug,
},
envValue,
warnings,
);
}
function jsonEqual(left: unknown, right: unknown): boolean {
@@ -2175,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
const seen = new Set<string>();
const out: CompanyPortabilityManifest["envInputs"] = [];
for (const value of values) {
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
@@ -2232,6 +2282,31 @@ function readAgentEnvInputs(
key,
description: asString(record.description) ?? null,
agentSlug,
projectSlug: null,
kind: record.kind === "plain" ? "plain" : "secret",
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
}];
});
}
function readProjectEnvInputs(
extension: Record<string, unknown>,
projectSlug: string,
): CompanyPortabilityManifest["envInputs"] {
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
if (!env) return [];
return Object.entries(env).flatMap(([key, value]) => {
if (!isPlainRecord(value)) return [];
const record = value as EnvInputRecord;
return [{
key,
description: asString(record.description) ?? null,
agentSlug: null,
projectSlug,
kind: record.kind === "plain" ? "plain" : "secret",
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
@@ -2531,12 +2606,14 @@ function buildManifestFromPackageFiles(
targetDate: asString(extension.targetDate),
color: asString(extension.color),
status: asString(extension.status),
env: normalizePortableProjectEnv(extension.env),
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
? extension.executionWorkspacePolicy
: null,
workspaces,
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
if (frontmatter.kind && frontmatter.kind !== "project") {
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
}
@@ -3144,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const project of selectedProjectRows) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
.slice(envInputsStart)
.filter((inputValue) => inputValue.projectSlug === slug),
);
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
files[projectPath] = buildMarkdown(
@@ -3167,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
) ?? undefined,
workspaces: portableWorkspaces.extension,
});
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
extension.inputs = {
env: buildEnvInputMap(projectEnvInputs),
};
}
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
}
@@ -3506,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const envInput of manifest.envInputs) {
if (envInput.portability === "system_dependent") {
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
const scope = envInput.agentSlug
? ` for agent ${envInput.agentSlug}`
: envInput.projectSlug
? ` for project ${envInput.projectSlug}`
: "";
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
}
}
@@ -4095,6 +4190,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
env: manifestProject.env,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};
+45 -10
View File
@@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
>;
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
}) {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
: { env: {}, secretKeys: new Set<string>() };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
...projectEnvResolution.env,
};
for (const key of projectEnvResolution.secretKeys) {
secretKeys.add(key);
}
}
return { resolvedConfig, secretKeys };
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
: null;
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueContext?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
const projectContext = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) =>
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
))
.then((rows) => rows[0] ?? null)
: null;
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
);
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
@@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
executionRunConfig,
);
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
+13 -2
View File
@@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
}
export function secretService(db: Db) {
type NormalizeEnvOptions = {
strictMode?: boolean;
fieldPath?: string;
};
async function getById(id: string) {
return db
.select()
@@ -94,10 +99,10 @@ export function secretService(db: Db) {
async function normalizeEnvConfig(
companyId: string,
envValue: unknown,
opts?: { strictMode?: boolean },
opts?: NormalizeEnvOptions,
): Promise<AgentEnvConfig> {
const record = asRecord(envValue);
if (!record) throw unprocessable("adapterConfig.env must be an object");
if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`);
const normalized: AgentEnvConfig = {};
for (const [key, rawBinding] of Object.entries(record)) {
@@ -292,6 +297,12 @@ export function secretService(db: Db) {
opts?: { strictMode?: boolean },
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
normalizeEnvBindingsForPersistence: async (
companyId: string,
envValue: unknown,
opts?: NormalizeEnvOptions,
) => normalizeEnvConfig(companyId, envValue, opts),
normalizeHireApprovalPayloadForPersistence: async (
companyId: string,
payload: Record<string, unknown>,
+275 -14
View File
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import { createHash, randomUUID } from "node:crypto";
@@ -101,6 +102,18 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
const DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES = 256 * 1024;
type ProcessOutputCapture = {
text: string;
truncated: boolean;
totalBytes: number;
};
type ProcessOutputAccumulator = {
append(chunk: string): void;
finish(): ProcessOutputCapture;
};
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
@@ -122,6 +135,128 @@ function stableStringify(value: unknown): string {
return JSON.stringify(value);
}
type WorkspaceLinkMismatch = {
packageName: string;
expectedPath: string;
actualPath: string | null;
};
function readJsonFile(filePath: string): Record<string, unknown> {
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
}
function findWorkspaceRoot(startCwd: string) {
let current = path.resolve(startCwd);
while (true) {
if (existsSync(path.join(current, "pnpm-workspace.yaml"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
const packagePaths = new Map<string, string>();
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
function visit(dirPath: string) {
if (!existsSync(dirPath)) return;
const packageJsonPath = path.join(dirPath, "package.json");
if (existsSync(packageJsonPath)) {
const packageJson = readJsonFile(packageJsonPath);
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
packagePaths.set(packageJson.name, dirPath);
}
}
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (ignoredDirNames.has(entry.name)) continue;
visit(path.join(dirPath, entry.name));
}
}
visit(path.join(rootDir, "packages"));
visit(path.join(rootDir, "server"));
visit(path.join(rootDir, "ui"));
visit(path.join(rootDir, "cli"));
return packagePaths;
}
function findServerWorkspaceLinkMismatches(rootDir: string): WorkspaceLinkMismatch[] {
const serverPackageJsonPath = path.join(rootDir, "server", "package.json");
if (!existsSync(serverPackageJsonPath)) return [];
const serverPackageJson = readJsonFile(serverPackageJsonPath);
const dependencies = {
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
};
const workspacePackagePaths = discoverWorkspacePackagePaths(rootDir);
const mismatches: WorkspaceLinkMismatch[] = [];
for (const [packageName, version] of Object.entries(dependencies)) {
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
const expectedPath = workspacePackagePaths.get(packageName);
if (!expectedPath) continue;
const normalizedExpectedPath = existsSync(expectedPath) ? path.resolve(realpathSync(expectedPath)) : path.resolve(expectedPath);
const linkPath = path.join(rootDir, "server", "node_modules", ...packageName.split("/"));
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
if (actualPath === normalizedExpectedPath) continue;
mismatches.push({
packageName,
expectedPath: normalizedExpectedPath,
actualPath,
});
}
return mismatches;
}
export async function ensureServerWorkspaceLinksCurrent(
startCwd: string,
opts?: {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
},
) {
const workspaceRoot = findWorkspaceRoot(startCwd);
if (!workspaceRoot) return;
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
if (mismatches.length === 0) return;
if (opts?.onLog) {
await opts.onLog("stdout", "[runtime] detected stale workspace package links for server; relinking dependencies...\n");
for (const mismatch of mismatches) {
await opts.onLog(
"stdout",
`[runtime] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}\n`,
);
}
}
for (const mismatch of mismatches) {
const linkPath = path.join(workspaceRoot, "server", "node_modules", ...mismatch.packageName.split("/"));
await fs.mkdir(path.dirname(linkPath), { recursive: true });
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(mismatch.expectedPath, linkPath);
}
const remainingMismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
if (remainingMismatches.length === 0) return;
throw new Error(
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
);
}
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) {
@@ -258,30 +393,96 @@ function formatCommandForDisplay(command: string, args: string[]) {
.join(" ");
}
function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator {
const limit = Math.max(1, Math.trunc(maxBytes));
let chunks: string[] = [];
let truncated = false;
let totalBytes = 0;
return {
append(chunk: string) {
if (!chunk) return;
chunks.push(chunk);
totalBytes += Buffer.byteLength(chunk, "utf8");
let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0);
if (currentBytes <= limit) return;
const combined = Buffer.from(chunks.join(""), "utf8");
const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8");
chunks = [tail];
truncated = true;
currentBytes = Buffer.byteLength(tail, "utf8");
if (currentBytes > limit) {
chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")];
}
},
finish(): ProcessOutputCapture {
const text = chunks.join("");
if (!truncated) {
return {
text,
truncated: false,
totalBytes,
};
}
return {
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${text}`,
truncated: true,
totalBytes,
};
},
};
}
async function executeProcess(input: {
command: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
maxStdoutBytes?: number;
maxStderrBytes?: number;
}): Promise<{
stdout: string;
stderr: string;
code: number | null;
stdoutTruncated: boolean;
stderrTruncated: boolean;
stdoutBytes: number;
stderrBytes: number;
}> {
const proc = await new Promise<{
stdout: ProcessOutputAccumulator;
stderr: ProcessOutputAccumulator;
code: number | null;
}>((resolve, reject) => {
const child = spawn(input.command, input.args, {
cwd: input.cwd,
stdio: ["ignore", "pipe", "pipe"],
env: input.env ?? process.env,
});
let stdout = "";
let stderr = "";
const stdout = createProcessOutputCapture(input.maxStdoutBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
const stderr = createProcessOutputCapture(input.maxStderrBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
stdout.append(String(chunk));
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
stderr.append(String(chunk));
});
child.on("error", reject);
child.on("close", (code) => resolve({ stdout, stderr, code }));
});
return proc;
const stdout = proc.stdout.finish();
const stderr = proc.stderr.finish();
return {
stdout: stdout.text,
stderr: stderr.text,
code: proc.code,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
stdoutBytes: stdout.totalBytes,
stderrBytes: stderr.totalBytes,
};
}
async function runGit(args: string[], cwd: string): Promise<string> {
@@ -377,8 +578,35 @@ function buildWorkspaceCommandEnv(input: {
return env;
}
function quoteShellArg(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
const patterns = [
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
/^(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
];
for (const pattern of patterns) {
const match = command.match(pattern);
if (!match?.groups) continue;
const relativePath = match.groups.relative;
const repoManagedPath = path.join(repoRoot, relativePath.slice(2));
if (!existsSync(repoManagedPath)) continue;
const prefix = match.groups.prefix ?? "";
const suffix = match.groups.suffix ?? "";
return `${prefix}${quoteShellArg(repoManagedPath)}${suffix}`;
}
return command;
}
async function runWorkspaceCommand(input: {
command: string;
resolvedCommand?: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
@@ -386,7 +614,7 @@ async function runWorkspaceCommand(input: {
const shell = resolveShell();
const proc = await executeProcess({
command: shell,
args: ["-c", input.command],
args: ["-c", input.resolvedCommand ?? input.command],
cwd: input.cwd,
env: input.env,
});
@@ -438,6 +666,15 @@ async function recordGitOperation(
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
metadata:
result.stdoutTruncated || result.stderrTruncated
? {
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
}
: null,
};
},
});
@@ -458,6 +695,7 @@ async function recordWorkspaceCommandOperation(
input: {
phase: "workspace_provision" | "workspace_teardown";
command: string;
resolvedCommand?: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
@@ -482,7 +720,7 @@ async function recordWorkspaceCommandOperation(
const shell = resolveShell();
const result = await executeProcess({
command: shell,
args: ["-c", input.command],
args: ["-c", input.resolvedCommand ?? input.command],
cwd: input.cwd,
env: input.env,
});
@@ -495,6 +733,15 @@ async function recordWorkspaceCommandOperation(
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
metadata:
result.stdoutTruncated || result.stderrTruncated
? {
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
}
: null,
};
},
});
@@ -522,10 +769,12 @@ async function provisionExecutionWorktree(input: {
}) {
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
if (!provisionCommand) return;
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: provisionCommand,
resolvedCommand: resolvedProvisionCommand,
cwd: input.worktreePath,
env: buildWorkspaceCommandEnv({
base: input.base,
@@ -542,6 +791,7 @@ async function provisionExecutionWorktree(input: {
worktreePath: input.worktreePath,
branchName: input.branchName,
created: input.created,
resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand,
},
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
});
@@ -769,6 +1019,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
}) {
const warnings: string[] = [];
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
const repoRoot = input.workspace.providerType === "git_worktree" && workspacePath
? await resolveGitRepoRootForWorkspaceCleanup(
workspacePath,
input.projectWorkspace?.cwd ?? null,
)
: null;
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
workspace: input.workspace,
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
@@ -784,9 +1040,13 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
for (const command of cleanupCommands) {
try {
const resolvedCommand = repoRoot
? resolveRepoManagedWorkspaceCommand(command, repoRoot)
: command;
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_teardown",
command,
resolvedCommand,
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
env: cleanupEnv,
label: `Execution workspace cleanup command "${command}"`,
@@ -795,6 +1055,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
workspacePath,
branchName: input.workspace.branchName,
providerType: input.workspace.providerType,
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
},
successMessage: `Completed cleanup command "${command}"\n`,
});
@@ -804,10 +1065,6 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
}
if (input.workspace.providerType === "git_worktree" && workspacePath) {
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
workspacePath,
input.projectWorkspace?.cwd ?? null,
);
const worktreeExists = await directoryExists(workspacePath);
if (worktreeExists) {
if (!repoRoot) {
@@ -1374,7 +1631,11 @@ async function startLocalRuntimeService(input: {
);
}
}
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
onLog: input.onLog,
});
const shell = resolveShell();
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,
+8 -1
View File
@@ -1,6 +1,8 @@
import { defineConfig } from "@playwright/test";
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100);
// Use a dedicated port so e2e tests always start their own server in local_trusted mode,
// even when the dev server is running on :3100 in authenticated mode.
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
const BASE_URL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
@@ -29,6 +31,11 @@ export default defineConfig({
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PORT: String(PORT),
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
},
},
outputDir: "./test-results",
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
+399
View File
@@ -0,0 +1,399 @@
import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
/**
* E2E: Signoff execution policy flow.
*
* Validates the full signoff lifecycle through the API and UI:
* 1. Create a company with executor + reviewer + approver agents
* 2. Create an issue with a two-stage execution policy (review approval)
* 3. Executor marks done issue routes to reviewer (in_review)
* 4. Reviewer approves issue routes to approver
* 5. Approver approves execution completes, issue marked done
* 6. Verify "changes requested" flow returns to executor
*
* Requires local_trusted deployment mode (set in playwright.config.ts webServer env).
*
* Agent auth flow:
* - Board request (local_trusted auto-auth) handles setup/teardown.
* - Agent-specific actions use API keys + heartbeat run IDs.
* - Reviewers/approvers invoke heartbeat runs (gets run IDs) then PATCH
* directly without checkout (checkout would force in_progress, breaking
* the in_review state the signoff policy requires).
*/
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const COMPANY_NAME = `E2E-Signoff-${Date.now()}`;
interface AgentAuth {
agentId: string;
token: string;
keyId: string;
request: APIRequestContext;
}
interface TestContext {
companyId: string;
companyPrefix: string;
executor: AgentAuth;
reviewer: AgentAuth;
approver: AgentAuth;
boardRequest: APIRequestContext;
issueIds: string[];
}
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
async function createAgentRequest(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
/** Invoke a heartbeat run for an agent, returning the run ID. */
async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promise<string> {
const res = await board.post(`${BASE_URL}/api/agents/${agentId}/heartbeat/invoke`);
expect(res.ok()).toBe(true);
const run = await res.json();
return run.id;
}
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
async function agentPatch(
board: APIRequestContext,
agent: AgentAuth,
issueId: string,
data: Record<string, unknown>,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data,
});
return res;
}
/** Checkout an issue as an agent, then PATCH it. Used for executor mark-done. */
async function agentCheckoutAndPatch(
board: APIRequestContext,
agent: AgentAuth,
issueId: string,
expectedStatuses: string[],
patchData: Record<string, unknown>,
) {
const runId = await invokeHeartbeat(board, agent.agentId);
// Checkout (sets executionRunId so PATCH is allowed)
const checkoutRes = await agent.request.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
headers: { "X-Paperclip-Run-Id": runId },
data: { agentId: agent.agentId, expectedStatuses },
});
if (!checkoutRes.ok()) {
// If agent checkout fails (e.g. run expired), fall back to board checkout
// then PATCH with the agent's identity
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
data: { agentId: agent.agentId, expectedStatuses },
});
if (!boardCheckout.ok()) {
throw new Error(`Board checkout failed: ${await boardCheckout.text()}`);
}
// Board PATCH (executor mark-done triggers signoff regardless of actor)
const res = await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
data: patchData,
});
return res;
}
// PATCH with agent identity
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": runId },
data: patchData,
});
return res;
}
async function setupCompany(boardRequest: APIRequestContext): Promise<TestContext> {
// Verify server is in local_trusted mode
const healthRes = await boardRequest.get(`${BASE_URL}/api/health`);
expect(healthRes.ok()).toBe(true);
const health = await healthRes.json();
if (health.deploymentMode !== "local_trusted") {
throw new Error(
`Signoff e2e tests require local_trusted deployment mode, ` +
`but server is in "${health.deploymentMode}" mode. ` +
`Set PAPERCLIP_DEPLOYMENT_MODE=local_trusted or use the webServer config.`,
);
}
// Create company
const companyRes = await boardRequest.post(`${BASE_URL}/api/companies`, {
data: { name: COMPANY_NAME },
});
if (!companyRes.ok()) {
const errBody = await companyRes.text();
throw new Error(`POST /api/companies → ${companyRes.status()}: ${errBody}`);
}
const company = await companyRes.json();
const companyId = company.id;
const companyPrefix = company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E";
// Helper: create agent + API key + request context
async function createAgent(name: string, role: string, title: string): Promise<AgentAuth> {
const agentRes = await boardRequest.post(`${BASE_URL}/api/companies/${companyId}/agents`, {
data: { name, role, title, adapterType: "process", adapterConfig: { command: "echo done" } },
});
expect(agentRes.ok()).toBe(true);
const agent = await agentRes.json();
const keyRes = await boardRequest.post(`${BASE_URL}/api/agents/${agent.id}/keys`, {
data: { name: `e2e-${name.toLowerCase()}` },
});
expect(keyRes.ok()).toBe(true);
const keyData = await keyRes.json();
return {
agentId: agent.id,
token: keyData.token,
keyId: keyData.id,
request: await createAgentRequest(keyData.token),
};
}
const executor = await createAgent("Executor", "engineer", "Software Engineer");
const reviewer = await createAgent("Reviewer", "qa", "QA Engineer");
const approver = await createAgent("Approver", "cto", "CTO");
return {
companyId,
companyPrefix,
executor,
reviewer,
approver,
boardRequest,
issueIds: [],
};
}
async function createIssueWithPolicy(ctx: TestContext, title: string, stages?: unknown[]) {
const defaultStages = [
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
{ type: "approval", participants: [{ type: "agent", agentId: ctx.approver.agentId }] },
];
const res = await ctx.boardRequest.post(`${BASE_URL}/api/companies/${ctx.companyId}/issues`, {
data: {
title,
status: "in_progress",
assigneeAgentId: ctx.executor.agentId,
executionPolicy: { stages: stages ?? defaultStages },
},
});
expect(res.ok()).toBe(true);
const issue = await res.json();
ctx.issueIds.push(issue.id);
return issue;
}
test.describe("Signoff execution policy", () => {
let ctx: TestContext;
test.beforeAll(async () => {
const boardRequest = await pwRequest.newContext({ baseURL: BASE_URL });
ctx = await setupCompany(boardRequest);
});
test.afterAll(async () => {
if (!ctx) return;
const board = ctx.boardRequest;
// Dispose agent request contexts
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
await agent.request.dispose();
}
// Clean up issues, keys, agents, company (best-effort)
for (const issueId of ctx.issueIds) {
await board.patch(`${BASE_URL}/api/issues/${issueId}`, {
data: { status: "cancelled", comment: "E2E test cleanup." },
}).catch(() => {});
}
for (const agent of [ctx.executor, ctx.reviewer, ctx.approver]) {
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}/keys/${agent.keyId}`).catch(() => {});
await board.delete(`${BASE_URL}/api/agents/${agent.agentId}`).catch(() => {});
}
await board.delete(`${BASE_URL}/api/companies/${ctx.companyId}`).catch(() => {});
await board.dispose();
});
test("happy path: executor → review → approval → done", async ({ page }) => {
const issue = await createIssueWithPolicy(ctx, "Signoff happy path");
const issueId = issue.id;
// Verify policy was saved
expect(issue.executionPolicy).toBeTruthy();
expect(issue.executionPolicy.stages).toHaveLength(2);
expect(issue.executionPolicy.stages[0].type).toBe("review");
expect(issue.executionPolicy.stages[1].type).toBe("approval");
// Step 1: Executor marks done → should route to reviewer
const step1Res = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Implemented the feature, ready for review." },
);
expect(step1Res.ok()).toBe(true);
const step1Issue = await step1Res.json();
expect(step1Issue.status).toBe("in_review");
expect(step1Issue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(step1Issue.executionState).toBeTruthy();
expect(step1Issue.executionState.status).toBe("pending");
expect(step1Issue.executionState.currentStageType).toBe("review");
expect(step1Issue.executionState.returnAssignee).toMatchObject({
type: "agent",
agentId: ctx.executor.agentId,
});
// Step 2: Navigate to issue in UI and verify execution label
await page.goto(`/${ctx.companyPrefix}/issues/${issue.identifier}`);
await expect(page.locator("text=Review pending")).toBeVisible({ timeout: 10_000 });
// Step 3: Reviewer approves → should route to approver
const step3Res = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "done", comment: "QA signoff complete. Looks good." },
);
expect(step3Res.ok()).toBe(true);
const step3Issue = await step3Res.json();
expect(step3Issue.status).toBe("in_review");
expect(step3Issue.assigneeAgentId).toBe(ctx.approver.agentId);
expect(step3Issue.executionState.status).toBe("pending");
expect(step3Issue.executionState.currentStageType).toBe("approval");
expect(step3Issue.executionState.completedStageIds).toHaveLength(1);
// Step 4: Verify UI shows approval pending
await page.reload();
await expect(page.locator("text=Approval pending")).toBeVisible({ timeout: 10_000 });
// Step 5: Approver approves → should complete
const step5Res = await agentPatch(
ctx.boardRequest, ctx.approver, issueId,
{ status: "done", comment: "Approved. Ship it." },
);
expect(step5Res.ok()).toBe(true);
const step5Issue = await step5Res.json();
expect(step5Issue.status).toBe("done");
expect(step5Issue.executionState.status).toBe("completed");
expect(step5Issue.executionState.completedStageIds).toHaveLength(2);
expect(step5Issue.executionState.lastDecisionOutcome).toBe("approved");
});
test("changes requested: reviewer bounces back to executor", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff changes requested");
const issueId = issue.id;
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Ready for review." },
);
expect(doneRes.ok()).toBe(true);
expect((await doneRes.json()).status).toBe("in_review");
// Reviewer requests changes → returns to executor
const changesRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "in_progress", comment: "Needs another pass on edge cases." },
);
expect(changesRes.ok()).toBe(true);
const changesIssue = await changesRes.json();
expect(changesIssue.status).toBe("in_progress");
expect(changesIssue.assigneeAgentId).toBe(ctx.executor.agentId);
expect(changesIssue.executionState.status).toBe("changes_requested");
expect(changesIssue.executionState.lastDecisionOutcome).toBe("changes_requested");
// Executor re-submits → goes back to reviewer (same stage)
const resubmitRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Fixed the edge cases." },
);
expect(resubmitRes.ok()).toBe(true);
const resubmitIssue = await resubmitRes.json();
expect(resubmitIssue.status).toBe("in_review");
expect(resubmitIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(resubmitIssue.executionState.status).toBe("pending");
expect(resubmitIssue.executionState.currentStageType).toBe("review");
});
test("comment required: approval without comment fails", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff comment required");
const issueId = issue.id;
// Executor marks done → routes to reviewer
await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Done." },
);
// Reviewer tries to approve without comment → should fail
const noCommentRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issueId,
{ status: "done" },
);
expect(noCommentRes.ok()).toBe(false);
const errorBody = await noCommentRes.json();
expect(JSON.stringify(errorBody)).toContain("comment");
});
test("non-participant cannot advance stage", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff access control");
const issueId = issue.id;
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issueId, ["in_progress"],
{ status: "done", comment: "Done." },
);
expect(doneRes.ok()).toBe(true);
// Verify issue is in_review with reviewer
const issueRes = await ctx.boardRequest.get(`${BASE_URL}/api/issues/${issueId}`);
const inReviewIssue = await issueRes.json();
expect(inReviewIssue.status).toBe("in_review");
expect(inReviewIssue.assigneeAgentId).toBe(ctx.reviewer.agentId);
expect(inReviewIssue.executionState.currentStageType).toBe("review");
// Non-participant (approver at this stage) tries to advance → should be rejected
const advanceRes = await agentPatch(
ctx.boardRequest, ctx.approver, issueId,
{ status: "done", comment: "I'm the approver, not the reviewer." },
);
expect(advanceRes.ok()).toBe(false);
expect(advanceRes.status()).toBeGreaterThanOrEqual(400);
});
test("review-only policy: reviewer approval completes execution", async () => {
const issue = await createIssueWithPolicy(ctx, "Signoff review-only", [
{ type: "review", participants: [{ type: "agent", agentId: ctx.reviewer.agentId }] },
]);
// Executor marks done → routes to reviewer
const doneRes = await agentCheckoutAndPatch(
ctx.boardRequest, ctx.executor, issue.id, ["in_progress"],
{ status: "done", comment: "Ready for review." },
);
expect(doneRes.ok()).toBe(true);
expect((await doneRes.json()).status).toBe("in_review");
// Reviewer approves → should complete immediately (no approval stage)
const approveRes = await agentPatch(
ctx.boardRequest, ctx.reviewer, issue.id,
{ status: "done", comment: "LGTM." },
);
expect(approveRes.ok()).toBe(true);
const doneIssue = await approveRes.json();
expect(doneIssue.status).toBe("done");
expect(doneIssue.executionState.status).toBe("completed");
expect(doneIssue.executionState.completedStageIds).toHaveLength(1);
});
});
+1 -263
View File
@@ -44,6 +44,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import { EnvVarEditor } from "./EnvVarEditor";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
@@ -1082,269 +1083,6 @@ function AdapterTypeDropdown({
);
}
function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") {
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
}
const entries = Object.entries(rec).map(([k, binding]) => {
if (typeof binding === "string") {
return {
key: k,
source: "plain" as const,
plainValue: binding,
secretId: "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "secret_ref"
) {
const recBinding = binding as { secretId?: unknown };
return {
key: k,
source: "secret" as const,
plainValue: "",
secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "plain"
) {
const recBinding = binding as { value?: unknown };
return {
key: k,
source: "plain" as const,
plainValue: typeof recBinding.value === "string" ? recBinding.value : "",
secretId: "",
};
}
return {
key: k,
source: "plain" as const,
plainValue: "",
secretId: "",
};
});
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
}
const [rows, setRows] = useState<Row[]>(() => toRows(value));
const [sealError, setSealError] = useState<string | null>(null);
const valueRef = useRef(value);
const emittingRef = useRef(false);
// Sync when value identity changes (overlay reset after save).
// Skip re-sync when the change was triggered by our own emit() to avoid
// reverting local row state (e.g. a secret-transition dropdown choice).
useEffect(() => {
if (emittingRef.current) {
emittingRef.current = false;
valueRef.current = value;
return;
}
if (value !== valueRef.current) {
valueRef.current = value;
setRows(toRows(value));
}
}, [value]);
function emit(nextRows: Row[]) {
const rec: Record<string, EnvBinding> = {};
for (const row of nextRows) {
const k = row.key.trim();
if (!k) continue;
if (row.source === "secret") {
if (row.secretId) {
rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
} else {
// Row is transitioning to secret but user hasn't picked one yet.
// Preserve the plain value so it isn't silently dropped.
rec[k] = { type: "plain", value: row.plainValue };
}
} else {
rec[k] = { type: "plain", value: row.plainValue };
}
}
emittingRef.current = true;
onChange(Object.keys(rec).length > 0 ? rec : undefined);
}
function updateRow(i: number, patch: Partial<Row>) {
const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
if (
withPatch[withPatch.length - 1].key ||
withPatch[withPatch.length - 1].plainValue ||
withPatch[withPatch.length - 1].secretId
) {
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(withPatch);
emit(withPatch);
}
function removeRow(i: number) {
const next = rows.filter((_, idx) => idx !== i);
if (
next.length === 0 ||
next[next.length - 1].key ||
next[next.length - 1].plainValue ||
next[next.length - 1].secretId
) {
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(next);
emit(next);
}
function defaultSecretName(key: string): string {
return key
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
async function sealRow(i: number) {
const row = rows[i];
if (!row) return;
const key = row.key.trim();
const plain = row.plainValue;
if (!key || plain.length === 0) return;
const suggested = defaultSecretName(key) || "secret";
const name = window.prompt("Secret name", suggested)?.trim();
if (!name) return;
try {
setSealError(null);
const created = await onCreateSecret(name, plain);
updateRow(i, {
source: "secret",
secretId: created.id,
});
} catch (err) {
setSealError(err instanceof Error ? err.message : "Failed to create secret");
}
}
return (
<div className="space-y-1.5">
{rows.map((row, i) => {
const isTrailing =
i === rows.length - 1 &&
!row.key &&
!row.plainValue &&
!row.secretId;
return (
<div key={i} className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
value={row.key}
onChange={(e) => updateRow(i, { key: e.target.value })}
/>
<select
className={cn(inputClass, "flex-[1] bg-background")}
value={row.source}
onChange={(e) =>
updateRow(i, {
source: e.target.value === "secret" ? "secret" : "plain",
...(e.target.value === "plain" ? { secretId: "" } : {}),
})
}
>
<option value="plain">Plain</option>
<option value="secret">Secret</option>
</select>
{row.source === "secret" ? (
<>
<select
className={cn(inputClass, "flex-[3] bg-background")}
value={row.secretId}
onChange={(e) => updateRow(i, { secretId: e.target.value })}
>
<option value="">Select secret...</option>
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{secret.name}
</option>
))}
</select>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Create secret from current plain value"
>
New
</button>
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.plainValue}
onChange={(e) => updateRow(i, { plainValue: e.target.value })}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(i)}
disabled={!row.key.trim() || !row.plainValue}
title="Store value as secret and replace with reference"
>
Seal
</button>
</>
)}
{!isTrailing ? (
<button
type="button"
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeRow(i)}
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<div className="w-[26px] shrink-0" />
)}
</div>
);
})}
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
<p className="text-[11px] text-muted-foreground/60">
PAPERCLIP_* variables are injected automatically at runtime.
</p>
</div>
);
}
function ModelDropdown({
models,
value,
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
type Row = {
key: string;
source: "plain" | "secret";
plainValue: string;
secretId: string;
};
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") {
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
}
const entries = Object.entries(rec).map(([key, binding]) => {
if (typeof binding === "string") {
return { key, source: "plain" as const, plainValue: binding, secretId: "" };
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "secret_ref"
) {
const record = binding as { secretId?: unknown };
return {
key,
source: "secret" as const,
plainValue: "",
secretId: typeof record.secretId === "string" ? record.secretId : "",
};
}
if (
typeof binding === "object" &&
binding !== null &&
"type" in binding &&
(binding as { type?: unknown }).type === "plain"
) {
const record = binding as { value?: unknown };
return {
key,
source: "plain" as const,
plainValue: typeof record.value === "string" ? record.value : "",
secretId: "",
};
}
return { key, source: "plain" as const, plainValue: "", secretId: "" };
});
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
}
export function EnvVarEditor({
value,
secrets,
onCreateSecret,
onChange,
}: {
value: Record<string, EnvBinding>;
secrets: CompanySecret[];
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
onChange: (env: Record<string, EnvBinding> | undefined) => void;
}) {
const [rows, setRows] = useState<Row[]>(() => toRows(value));
const [sealError, setSealError] = useState<string | null>(null);
const valueRef = useRef(value);
const emittingRef = useRef(false);
useEffect(() => {
if (emittingRef.current) {
emittingRef.current = false;
valueRef.current = value;
return;
}
if (value !== valueRef.current) {
valueRef.current = value;
setRows(toRows(value));
}
}, [value]);
function emit(nextRows: Row[]) {
const rec: Record<string, EnvBinding> = {};
for (const row of nextRows) {
const key = row.key.trim();
if (!key) continue;
if (row.source === "secret") {
if (row.secretId) {
rec[key] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
} else {
rec[key] = { type: "plain", value: row.plainValue };
}
} else {
rec[key] = { type: "plain", value: row.plainValue };
}
}
emittingRef.current = true;
onChange(Object.keys(rec).length > 0 ? rec : undefined);
}
function updateRow(index: number, patch: Partial<Row>) {
const withPatch = rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
if (
withPatch[withPatch.length - 1].key ||
withPatch[withPatch.length - 1].plainValue ||
withPatch[withPatch.length - 1].secretId
) {
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(withPatch);
emit(withPatch);
}
function removeRow(index: number) {
const next = rows.filter((_, rowIndex) => rowIndex !== index);
if (
next.length === 0 ||
next[next.length - 1].key ||
next[next.length - 1].plainValue ||
next[next.length - 1].secretId
) {
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
}
setRows(next);
emit(next);
}
function defaultSecretName(key: string) {
return key
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 64);
}
async function sealRow(index: number) {
const row = rows[index];
if (!row) return;
const key = row.key.trim();
const plain = row.plainValue;
if (!key || plain.length === 0) return;
const suggested = defaultSecretName(key) || "secret";
const name = window.prompt("Secret name", suggested)?.trim();
if (!name) return;
try {
setSealError(null);
const created = await onCreateSecret(name, plain);
updateRow(index, { source: "secret", secretId: created.id });
} catch (error) {
setSealError(error instanceof Error ? error.message : "Failed to create secret");
}
}
return (
<div className="space-y-1.5">
{rows.map((row, index) => {
const isTrailing =
index === rows.length - 1 &&
!row.key &&
!row.plainValue &&
!row.secretId;
return (
<div key={index} className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
value={row.key}
onChange={(event) => updateRow(index, { key: event.target.value })}
/>
<select
className={cn(inputClass, "flex-[1] bg-background")}
value={row.source}
onChange={(event) =>
updateRow(index, {
source: event.target.value === "secret" ? "secret" : "plain",
...(event.target.value === "plain" ? { secretId: "" } : {}),
})
}
>
<option value="plain">Plain</option>
<option value="secret">Secret</option>
</select>
{row.source === "secret" ? (
<>
<select
className={cn(inputClass, "flex-[3] bg-background")}
value={row.secretId}
onChange={(event) => updateRow(index, { secretId: event.target.value })}
>
<option value="">Select secret...</option>
{secrets.map((secret) => (
<option key={secret.id} value={secret.id}>
{secret.name}
</option>
))}
</select>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(index)}
disabled={!row.key.trim() || !row.plainValue}
title="Create secret from current plain value"
>
New
</button>
</>
) : (
<>
<input
className={cn(inputClass, "flex-[3]")}
placeholder="value"
value={row.plainValue}
onChange={(event) => updateRow(index, { plainValue: event.target.value })}
/>
<button
type="button"
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
onClick={() => sealRow(index)}
disabled={!row.key.trim() || !row.plainValue}
title="Store value as secret and replace with reference"
>
Seal
</button>
</>
)}
{!isTrailing ? (
<button
type="button"
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => removeRow(index)}
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<div className="w-[26px] shrink-0" />
)}
</div>
);
})}
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
<p className="text-[11px] text-muted-foreground/60">
PAPERCLIP_* variables are injected automatically at runtime.
</p>
</div>
);
}
+38
View File
@@ -7,6 +7,7 @@ import { cn, formatDate } from "../lib/utils";
import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
@@ -19,6 +20,7 @@ import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvVarEditor } from "./EnvVarEditor";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -43,6 +45,7 @@ export type ProjectConfigFieldKey =
| "description"
| "status"
| "goals"
| "env"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_base_ref"
@@ -245,6 +248,21 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const linkedGoalIds = project.goalIds.length > 0
? project.goalIds
@@ -583,6 +601,26 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</Popover>
)}
</PropertyRow>
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
valueClassName="space-y-2"
>
<div className="space-y-2">
<EnvVarEditor
value={project.env ?? {}}
secrets={availableSecrets}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
<p className="text-[11px] text-muted-foreground">
Applied to all runs for issues in this project. Project values override agent env on key conflicts.
</p>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
@@ -58,6 +58,7 @@ function createProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#22c55e",
env: null,
pauseReason: null,
pausedAt: null,
archivedAt: null,
@@ -45,6 +45,7 @@ function makeProject(id: string, name: string): Project {
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
+25
View File
@@ -20,4 +20,29 @@ describe("company routes", () => {
"/execution-workspaces/workspace-123",
);
});
/**
* Regression tests for https://github.com/paperclipai/paperclip/issues/2910
*
* The Export and Import links on the Company Settings page used plain
* `<a href="/company/export">` anchors which bypass the router's Link
* wrapper. Without the wrapper, the company prefix is never applied and
* the links resolve to `/company/export` instead of `/:prefix/company/export`,
* producing a "Company not found" error.
*
* The fix replaces the `<a>` elements with the prefix-aware `<Link>` from
* `@/lib/router`. These tests assert that the underlying `applyCompanyPrefix`
* utility (used by that Link) correctly rewrites the export/import paths.
*/
it("applies company prefix to /company/export", () => {
expect(applyCompanyPrefix("/company/export", "PAP")).toBe("/PAP/company/export");
});
it("applies company prefix to /company/import", () => {
expect(applyCompanyPrefix("/company/import", "PAP")).toBe("/PAP/company/import");
});
it("does not double-apply the prefix if already present", () => {
expect(applyCompanyPrefix("/PAP/company/export", "PAP")).toBe("/PAP/company/export");
});
});
+5 -4
View File
@@ -1,4 +1,5 @@
import { ChangeEvent, useEffect, useState } from "react";
import { Link } from "@/lib/router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
@@ -548,16 +549,16 @@ export function CompanySettings() {
</p>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href="/company/export">
<Link to="/company/export">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export
</a>
</Link>
</Button>
<Button size="sm" variant="outline" asChild>
<a href="/company/import">
<Link to="/company/import">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import
</a>
</Link>
</Button>
</div>
</div>