forked from farhoodlabs/paperclip
merge master into pap-1167-app-ui-bundle
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }]],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user