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