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

This commit is contained in:
dotta 2026-04-07 07:10:14 -05:00
commit 2c2e13eac2
42 changed files with 15528 additions and 428 deletions

View file

@ -1149,6 +1149,7 @@ describe("company portability", () => {
key: "ANTHROPIC_API_KEY",
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
@ -1158,6 +1159,7 @@ describe("company portability", () => {
key: "GH_TOKEN",
description: "Provide GH_TOKEN for agent claudecoder",
agentSlug: "claudecoder",
projectSlug: null,
kind: "secret",
requirement: "optional",
defaultValue: "",
@ -1166,6 +1168,128 @@ describe("company portability", () => {
]);
});
it("exports project env as portable inputs without concrete values", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
DOCS_MODE: {
type: "plain",
value: "strict",
},
GITHUB_TOKEN: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "latest",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("OPENAI_API_KEY:");
expect(extension).toContain("DOCS_MODE:");
expect(extension).toContain("GITHUB_TOKEN:");
expect(extension).not.toContain("sk-project-secret");
expect(extension).not.toContain('type: "secret_ref"');
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
expect(extension).toContain('default: "strict"');
expect(extension).toContain('kind: "secret"');
expect(extension).toContain('kind: "plain"');
});
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
const portability = companyPortabilityService({} as any);
projectSvc.list.mockResolvedValue([
{
id: "project-1",
name: "Launch",
urlKey: "launch",
description: "Ship it",
leadAgentId: "agent-1",
targetDate: null,
color: null,
status: "planned",
env: {
OPENAI_API_KEY: {
type: "plain",
value: "sk-project-secret",
},
},
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
]);
const exported = await portability.exportBundle("company-1", {
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
});
const preview = await portability.previewImport({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: false,
agents: false,
projects: true,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: "all",
collisionStrategy: "rename",
});
expect(preview.errors).toEqual([]);
expect(preview.envInputs).toContainEqual({
key: "OPENAI_API_KEY",
description: "Optional default for OPENAI_API_KEY on project launch",
agentSlug: null,
projectSlug: "launch",
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
});
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
const portability = companyPortabilityService({} as any);

View 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");
});
});

View file

@ -63,4 +63,14 @@ describe("dev server status helpers", () => {
waitingForIdle: true,
});
});
it("ignores oversized persisted status files", () => {
const filePath = createTempStatusFile({
dirty: true,
changedPathsSample: ["x".repeat(70 * 1024)],
pendingMigrations: [],
});
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
});
});

View 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();
});
});

View file

@ -22,6 +22,9 @@ const mockGoalService = vi.hoisted(() => ({
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackProjectCreated = vi.hoisted(() => vi.fn());
const mockTrackGoalCreated = vi.hoisted(() => vi.fn());
@ -46,6 +49,7 @@ vi.mock("../services/index.js", () => ({
goalService: () => mockGoalService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
secretService: () => mockSecretService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
@ -77,6 +81,7 @@ describe("project and goal telemetry routes", () => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.create.mockResolvedValue({
id: "project-1",
companyId: "company-1",

View 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"],
},
}),
);
});
});

View file

@ -20,6 +20,7 @@ import {
import { eq } from "drizzle-orm";
import {
cleanupExecutionWorkspaceArtifacts,
ensureServerWorkspaceLinksCurrent,
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
reconcilePersistedRuntimeServicesOnStartup,
@ -187,6 +188,75 @@ describe("sanitizeRuntimeServiceBaseEnv", () => {
});
});
describe("ensureServerWorkspaceLinksCurrent", () => {
it("relinks stale server workspace dependencies inside the current repo root", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-"));
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-stale-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
const stalePackageDir = path.join(staleRoot, "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(stalePackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.writeFile(
path.join(stalePackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(expectedPackageDir));
});
it("skips relinking when server workspace dependencies already point at the repo", async () => {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-current-"));
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
const expectedPackageDir = path.join(repoRoot, "packages", "db");
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
await fs.mkdir(expectedPackageDir, { recursive: true });
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
await fs.writeFile(
path.join(repoRoot, "server", "package.json"),
JSON.stringify({
name: "@paperclipai/server",
dependencies: {
"@paperclipai/db": "workspace:*",
},
}),
"utf8",
);
await fs.writeFile(
path.join(expectedPackageDir, "package.json"),
JSON.stringify({ name: "@paperclipai/db" }),
"utf8",
);
await fs.symlink(expectedPackageDir, path.join(serverNodeModulesScopeDir, "db"));
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
});
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
@ -413,6 +483,96 @@ describe("realizeExecutionWorkspace", () => {
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
it("uses the latest repo-managed provision script when reusing an existing worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf 'v1\\n' > .paperclip-provision-version",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add initial provision script"]);
const initial = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-449",
title: "Reuse latest provision script",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v1\n");
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf 'v2\\n' > .paperclip-provision-version",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Update provision script"]);
await expect(fs.readFile(path.join(initial.cwd, "scripts", "provision.sh"), "utf8")).resolves.toContain("v1");
const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-449",
title: "Reuse latest provision script",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-version"), "utf8")).resolves.toBe("v2\n");
});
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
const repoRoot = await createTempRepo();
const previousCwd = process.cwd();
@ -663,9 +823,82 @@ describe("realizeExecutionWorkspace", () => {
await fs.realpath(path.join(repoRoot, "packages", "shared")),
);
},
15_000,
30_000,
);
it("provisions successfully when install is needed but there are no symlinked node_modules to move", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify(
{
name: "workspace-root",
private: true,
packageManager: "pnpm@9.15.4",
},
null,
2,
),
"utf8",
);
await fs.writeFile(
path.join(repoRoot, "pnpm-lock.yaml"),
[
"lockfileVersion: '9.0'",
"",
"settings:",
" autoInstallPeers: true",
" excludeLinksFromLockfile: false",
"",
"importers:",
" .: {}",
"",
].join("\n"),
"utf8",
);
await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
await fs.mkdir(path.join(repoRoot, "node_modules"), { recursive: true });
await fs.writeFile(path.join(repoRoot, "node_modules", ".keep"), "", "utf8");
await runGit(repoRoot, ["add", "package.json", "pnpm-lock.yaml", "scripts/provision-worktree.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add minimal provision fixture"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-552",
title: "Install without moved symlinks",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip", "config.json"), "utf8")).resolves.toContain(
"\"database\"",
);
}, 30_000);
it("records worktree setup and provision operations when a recorder is provided", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
@ -724,6 +957,57 @@ describe("realizeExecutionWorkspace", () => {
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
});
it("truncates oversized provision command output before storing it in memory", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "noisy.js"),
'process.stdout.write("x".repeat(400000));\n',
"utf8",
);
await runGit(repoRoot, ["add", "scripts/noisy.js"]);
await runGit(repoRoot, ["commit", "-m", "Add noisy provision script"]);
await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "node ./scripts/noisy.js",
},
},
issue: {
id: "issue-1",
identifier: "PAP-1142",
title: "Limit noisy provision output",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
const provisionOperation = operations.find((operation) => operation.phase === "workspace_provision");
expect(provisionOperation?.result.metadata).toMatchObject({
stdoutTruncated: true,
stderrTruncated: false,
});
expect(provisionOperation?.result.stdout).toContain("[output truncated to last");
expect(provisionOperation?.result.stdout?.length ?? 0).toBeLessThan(300000);
});
it("reuses an existing branch without resetting it when recreating a missing worktree", async () => {
const repoRoot = await createTempRepo();
const branchName = "PAP-450-recreate-missing-worktree";

View file

@ -1,4 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
export type PersistedDevServerStatus = {
dirty: boolean;
@ -44,6 +46,9 @@ export function readPersistedDevServerStatus(
if (!filePath || !existsSync(filePath)) return null;
try {
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) {
return null;
}
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);

View file

@ -9,7 +9,7 @@ import {
} from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
@ -18,7 +18,9 @@ import { getTelemetryClient } from "../telemetry.js";
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
@ -82,6 +84,13 @@ export function projectRoutes(db: Db) {
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
if (projectData.env !== undefined) {
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
companyId,
projectData.env,
{ strictMode: strictSecretsMode, fieldPath: "env" },
);
}
const project = await svc.create(companyId, projectData);
let createdWorkspaceId: string | null = null;
if (workspace) {
@ -107,6 +116,7 @@ export function projectRoutes(db: Db) {
details: {
name: project.name,
workspaceId: createdWorkspaceId,
envKeys: project.env ? Object.keys(project.env).sort() : [],
},
});
const telemetryClient = getTelemetryClient();
@ -128,6 +138,12 @@ export function projectRoutes(db: Db) {
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
if (body.env !== undefined) {
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
strictMode: strictSecretsMode,
fieldPath: "env",
});
}
const project = await svc.update(id, body);
if (!project) {
res.status(404).json({ error: "Project not found" });
@ -143,7 +159,13 @@ export function projectRoutes(db: Db) {
action: "project.updated",
entityType: "project",
entityId: project.id,
details: req.body,
details: {
changedKeys: Object.keys(req.body).sort(),
envKeys:
body.env && typeof body.env === "object" && !Array.isArray(body.env)
? Object.keys(body.env as Record<string, unknown>).sort()
: undefined,
},
});
res.json(project);

View file

@ -27,6 +27,7 @@ import type {
CompanyPortabilitySidebarOrder,
CompanyPortabilitySkillManifestEntry,
CompanySkill,
AgentEnvConfig,
RoutineVariable,
} from "@paperclipai/shared";
import {
@ -39,6 +40,7 @@ import {
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@ -387,6 +389,88 @@ function isSensitiveEnvKey(key: string) {
);
}
function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
const parsed = envConfigSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function extractPortableScopedEnvInputs(
scope: {
label: string;
warningPrefix: string;
agentSlug: string | null;
projectSlug: string | null;
},
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
for (const [key, binding] of Object.entries(env)) {
if (key.toUpperCase() === "PATH") {
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
continue;
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
inputs.push({
key,
description: `Provide ${key} for ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
continue;
}
if (isPlainRecord(binding) && binding.type === "plain") {
const defaultValue = asString(binding.value);
const isSensitive = isSensitiveEnvKey(key);
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: isSensitive ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitive ? "" : defaultValue ?? "",
portability,
});
continue;
}
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on ${scope.label}`,
agentSlug: scope.agentSlug,
projectSlug: scope.projectSlug,
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
portability,
});
}
}
return inputs;
}
type ResolvedSource = {
manifest: CompanyPortabilityManifest;
files: Record<string, CompanyPortabilityFileEntry>;
@ -419,6 +503,7 @@ type ProjectLike = {
targetDate: string | null;
color: string | null;
status: string;
env: Record<string, unknown> | null;
executionWorkspacePolicy: Record<string, unknown> | null;
workspaces?: Array<{
id: string;
@ -1528,68 +1613,33 @@ function extractPortableEnvInputs(
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
if (!isPlainRecord(envValue)) return [];
const env = envValue as Record<string, unknown>;
const inputs: CompanyPortabilityEnvInput[] = [];
return extractPortableScopedEnvInputs(
{
label: `agent ${agentSlug}`,
warningPrefix: `Agent ${agentSlug}`,
agentSlug,
projectSlug: null,
},
envValue,
warnings,
);
}
for (const [key, binding] of Object.entries(env)) {
if (key.toUpperCase() === "PATH") {
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
continue;
}
if (isPlainRecord(binding) && binding.type === "secret_ref") {
inputs.push({
key,
description: `Provide ${key} for agent ${agentSlug}`,
agentSlug,
kind: "secret",
requirement: "optional",
defaultValue: "",
portability: "portable",
});
continue;
}
if (isPlainRecord(binding) && binding.type === "plain") {
const defaultValue = asString(binding.value);
const isSensitive = isSensitiveEnvKey(key);
const portability = defaultValue && isAbsoluteCommand(defaultValue)
? "system_dependent"
: "portable";
if (portability === "system_dependent") {
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on agent ${agentSlug}`,
agentSlug,
kind: isSensitive ? "secret" : "plain",
requirement: "optional",
defaultValue: isSensitive ? "" : defaultValue ?? "",
portability,
});
continue;
}
if (typeof binding === "string") {
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
if (portability === "system_dependent") {
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
}
inputs.push({
key,
description: `Optional default for ${key} on agent ${agentSlug}`,
agentSlug,
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
requirement: "optional",
defaultValue: binding,
portability,
});
}
}
return inputs;
function extractPortableProjectEnvInputs(
projectSlug: string,
envValue: unknown,
warnings: string[],
): CompanyPortabilityEnvInput[] {
return extractPortableScopedEnvInputs(
{
label: `project ${projectSlug}`,
warningPrefix: `Project ${projectSlug}`,
agentSlug: null,
projectSlug,
},
envValue,
warnings,
);
}
function jsonEqual(left: unknown, right: unknown): boolean {
@ -2175,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
const seen = new Set<string>();
const out: CompanyPortabilityManifest["envInputs"] = [];
for (const value of values) {
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
@ -2232,6 +2282,31 @@ function readAgentEnvInputs(
key,
description: asString(record.description) ?? null,
agentSlug,
projectSlug: null,
kind: record.kind === "plain" ? "plain" : "secret",
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
}];
});
}
function readProjectEnvInputs(
extension: Record<string, unknown>,
projectSlug: string,
): CompanyPortabilityManifest["envInputs"] {
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
if (!env) return [];
return Object.entries(env).flatMap(([key, value]) => {
if (!isPlainRecord(value)) return [];
const record = value as EnvInputRecord;
return [{
key,
description: asString(record.description) ?? null,
agentSlug: null,
projectSlug,
kind: record.kind === "plain" ? "plain" : "secret",
requirement: record.requirement === "required" ? "required" : "optional",
defaultValue: typeof record.default === "string" ? record.default : null,
@ -2531,12 +2606,14 @@ function buildManifestFromPackageFiles(
targetDate: asString(extension.targetDate),
color: asString(extension.color),
status: asString(extension.status),
env: normalizePortableProjectEnv(extension.env),
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
? extension.executionWorkspacePolicy
: null,
workspaces,
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
if (frontmatter.kind && frontmatter.kind !== "project") {
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
}
@ -3144,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const project of selectedProjectRows) {
const slug = projectSlugById.get(project.id)!;
const projectPath = `projects/${slug}/PROJECT.md`;
const envInputsStart = envInputs.length;
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
envInputs.push(...exportedEnvInputs);
const projectEnvInputs = dedupeEnvInputs(
envInputs
.slice(envInputsStart)
.filter((inputValue) => inputValue.projectSlug === slug),
);
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
files[projectPath] = buildMarkdown(
@ -3167,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
) ?? undefined,
workspaces: portableWorkspaces.extension,
});
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
extension.inputs = {
env: buildEnvInputMap(projectEnvInputs),
};
}
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
}
@ -3506,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const envInput of manifest.envInputs) {
if (envInput.portability === "system_dependent") {
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
const scope = envInput.agentSlug
? ` for agent ${envInput.agentSlug}`
: envInput.projectSlug
? ` for project ${envInput.projectSlug}`
: "";
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
}
}
@ -4095,6 +4190,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
? manifestProject.status as typeof PROJECT_STATUSES[number]
: "backlog",
env: manifestProject.env,
executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy),
};

View file

@ -86,6 +86,36 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
type RuntimeConfigSecretResolver = Pick<
ReturnType<typeof secretService>,
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
>;
export async function resolveExecutionRunAdapterConfig(input: {
companyId: string;
executionRunConfig: Record<string, unknown>;
projectEnv: unknown;
secretsSvc: RuntimeConfigSecretResolver;
}) {
const { config: resolvedConfig, secretKeys } = await input.secretsSvc.resolveAdapterConfigForRuntime(
input.companyId,
input.executionRunConfig,
);
const projectEnvResolution = input.projectEnv
? await input.secretsSvc.resolveEnvBindings(input.companyId, input.projectEnv)
: { env: {}, secretKeys: new Set<string>() };
if (Object.keys(projectEnvResolution.env).length > 0) {
resolvedConfig.env = {
...parseObject(resolvedConfig.env),
...projectEnvResolution.env,
};
for (const key of projectEnvResolution.secretKeys) {
secretKeys.add(key);
}
}
return { resolvedConfig, secretKeys };
}
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
@ -2309,17 +2339,20 @@ export function heartbeatService(db: Db) {
: null;
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueContext?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
const projectContext = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
env: projects.env,
})
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) =>
gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
))
.then((rows) => rows[0] ?? null)
: null;
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
isolatedWorkspacesEnabled,
);
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
@ -2416,10 +2449,12 @@ export function heartbeatService(db: Db) {
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
companyId: agent.companyId,
executionRunConfig,
);
projectEnv: projectContext?.env ?? null,
secretsSvc,
});
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,

View file

@ -39,6 +39,11 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
}
export function secretService(db: Db) {
type NormalizeEnvOptions = {
strictMode?: boolean;
fieldPath?: string;
};
async function getById(id: string) {
return db
.select()
@ -94,10 +99,10 @@ export function secretService(db: Db) {
async function normalizeEnvConfig(
companyId: string,
envValue: unknown,
opts?: { strictMode?: boolean },
opts?: NormalizeEnvOptions,
): Promise<AgentEnvConfig> {
const record = asRecord(envValue);
if (!record) throw unprocessable("adapterConfig.env must be an object");
if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`);
const normalized: AgentEnvConfig = {};
for (const [key, rawBinding] of Object.entries(record)) {
@ -292,6 +297,12 @@ export function secretService(db: Db) {
opts?: { strictMode?: boolean },
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
normalizeEnvBindingsForPersistence: async (
companyId: string,
envValue: unknown,
opts?: NormalizeEnvOptions,
) => normalizeEnvConfig(companyId, envValue, opts),
normalizeHireApprovalPayloadForPersistence: async (
companyId: string,
payload: Record<string, unknown>,

View file

@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import { createHash, randomUUID } from "node:crypto";
@ -101,6 +102,18 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
const DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES = 256 * 1024;
type ProcessOutputCapture = {
text: string;
truncated: boolean;
totalBytes: number;
};
type ProcessOutputAccumulator = {
append(chunk: string): void;
finish(): ProcessOutputCapture;
};
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
@ -122,6 +135,128 @@ function stableStringify(value: unknown): string {
return JSON.stringify(value);
}
type WorkspaceLinkMismatch = {
packageName: string;
expectedPath: string;
actualPath: string | null;
};
function readJsonFile(filePath: string): Record<string, unknown> {
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
}
function findWorkspaceRoot(startCwd: string) {
let current = path.resolve(startCwd);
while (true) {
if (existsSync(path.join(current, "pnpm-workspace.yaml"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
const packagePaths = new Map<string, string>();
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
function visit(dirPath: string) {
if (!existsSync(dirPath)) return;
const packageJsonPath = path.join(dirPath, "package.json");
if (existsSync(packageJsonPath)) {
const packageJson = readJsonFile(packageJsonPath);
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
packagePaths.set(packageJson.name, dirPath);
}
}
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (ignoredDirNames.has(entry.name)) continue;
visit(path.join(dirPath, entry.name));
}
}
visit(path.join(rootDir, "packages"));
visit(path.join(rootDir, "server"));
visit(path.join(rootDir, "ui"));
visit(path.join(rootDir, "cli"));
return packagePaths;
}
function findServerWorkspaceLinkMismatches(rootDir: string): WorkspaceLinkMismatch[] {
const serverPackageJsonPath = path.join(rootDir, "server", "package.json");
if (!existsSync(serverPackageJsonPath)) return [];
const serverPackageJson = readJsonFile(serverPackageJsonPath);
const dependencies = {
...(serverPackageJson.dependencies as Record<string, unknown> | undefined),
...(serverPackageJson.devDependencies as Record<string, unknown> | undefined),
};
const workspacePackagePaths = discoverWorkspacePackagePaths(rootDir);
const mismatches: WorkspaceLinkMismatch[] = [];
for (const [packageName, version] of Object.entries(dependencies)) {
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
const expectedPath = workspacePackagePaths.get(packageName);
if (!expectedPath) continue;
const normalizedExpectedPath = existsSync(expectedPath) ? path.resolve(realpathSync(expectedPath)) : path.resolve(expectedPath);
const linkPath = path.join(rootDir, "server", "node_modules", ...packageName.split("/"));
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
if (actualPath === normalizedExpectedPath) continue;
mismatches.push({
packageName,
expectedPath: normalizedExpectedPath,
actualPath,
});
}
return mismatches;
}
export async function ensureServerWorkspaceLinksCurrent(
startCwd: string,
opts?: {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
},
) {
const workspaceRoot = findWorkspaceRoot(startCwd);
if (!workspaceRoot) return;
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
if (mismatches.length === 0) return;
if (opts?.onLog) {
await opts.onLog("stdout", "[runtime] detected stale workspace package links for server; relinking dependencies...\n");
for (const mismatch of mismatches) {
await opts.onLog(
"stdout",
`[runtime] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}\n`,
);
}
}
for (const mismatch of mismatches) {
const linkPath = path.join(workspaceRoot, "server", "node_modules", ...mismatch.packageName.split("/"));
await fs.mkdir(path.dirname(linkPath), { recursive: true });
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(mismatch.expectedPath, linkPath);
}
const remainingMismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
if (remainingMismatches.length === 0) return;
throw new Error(
`Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
);
}
export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) {
@ -258,30 +393,96 @@ function formatCommandForDisplay(command: string, args: string[]) {
.join(" ");
}
function createProcessOutputCapture(maxBytes: number): ProcessOutputAccumulator {
const limit = Math.max(1, Math.trunc(maxBytes));
let chunks: string[] = [];
let truncated = false;
let totalBytes = 0;
return {
append(chunk: string) {
if (!chunk) return;
chunks.push(chunk);
totalBytes += Buffer.byteLength(chunk, "utf8");
let currentBytes = chunks.reduce((sum, value) => sum + Buffer.byteLength(value, "utf8"), 0);
if (currentBytes <= limit) return;
const combined = Buffer.from(chunks.join(""), "utf8");
const tail = combined.subarray(Math.max(0, combined.length - limit)).toString("utf8");
chunks = [tail];
truncated = true;
currentBytes = Buffer.byteLength(tail, "utf8");
if (currentBytes > limit) {
chunks = [Buffer.from(tail, "utf8").subarray(Math.max(0, currentBytes - limit)).toString("utf8")];
}
},
finish(): ProcessOutputCapture {
const text = chunks.join("");
if (!truncated) {
return {
text,
truncated: false,
totalBytes,
};
}
return {
text: `[output truncated to last ${limit} bytes; total ${totalBytes} bytes]\n${text}`,
truncated: true,
totalBytes,
};
},
};
}
async function executeProcess(input: {
command: string;
args: string[];
cwd: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ stdout: string; stderr: string; code: number | null }> {
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
maxStdoutBytes?: number;
maxStderrBytes?: number;
}): Promise<{
stdout: string;
stderr: string;
code: number | null;
stdoutTruncated: boolean;
stderrTruncated: boolean;
stdoutBytes: number;
stderrBytes: number;
}> {
const proc = await new Promise<{
stdout: ProcessOutputAccumulator;
stderr: ProcessOutputAccumulator;
code: number | null;
}>((resolve, reject) => {
const child = spawn(input.command, input.args, {
cwd: input.cwd,
stdio: ["ignore", "pipe", "pipe"],
env: input.env ?? process.env,
});
let stdout = "";
let stderr = "";
const stdout = createProcessOutputCapture(input.maxStdoutBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
const stderr = createProcessOutputCapture(input.maxStderrBytes ?? DEFAULT_EXECUTE_PROCESS_OUTPUT_BYTES);
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
stdout.append(String(chunk));
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
stderr.append(String(chunk));
});
child.on("error", reject);
child.on("close", (code) => resolve({ stdout, stderr, code }));
});
return proc;
const stdout = proc.stdout.finish();
const stderr = proc.stderr.finish();
return {
stdout: stdout.text,
stderr: stderr.text,
code: proc.code,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
stdoutBytes: stdout.totalBytes,
stderrBytes: stderr.totalBytes,
};
}
async function runGit(args: string[], cwd: string): Promise<string> {
@ -377,8 +578,35 @@ function buildWorkspaceCommandEnv(input: {
return env;
}
function quoteShellArg(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
const patterns = [
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
/^(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
];
for (const pattern of patterns) {
const match = command.match(pattern);
if (!match?.groups) continue;
const relativePath = match.groups.relative;
const repoManagedPath = path.join(repoRoot, relativePath.slice(2));
if (!existsSync(repoManagedPath)) continue;
const prefix = match.groups.prefix ?? "";
const suffix = match.groups.suffix ?? "";
return `${prefix}${quoteShellArg(repoManagedPath)}${suffix}`;
}
return command;
}
async function runWorkspaceCommand(input: {
command: string;
resolvedCommand?: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
@ -386,7 +614,7 @@ async function runWorkspaceCommand(input: {
const shell = resolveShell();
const proc = await executeProcess({
command: shell,
args: ["-c", input.command],
args: ["-c", input.resolvedCommand ?? input.command],
cwd: input.cwd,
env: input.env,
});
@ -438,6 +666,15 @@ async function recordGitOperation(
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
metadata:
result.stdoutTruncated || result.stderrTruncated
? {
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
}
: null,
};
},
});
@ -458,6 +695,7 @@ async function recordWorkspaceCommandOperation(
input: {
phase: "workspace_provision" | "workspace_teardown";
command: string;
resolvedCommand?: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
@ -482,7 +720,7 @@ async function recordWorkspaceCommandOperation(
const shell = resolveShell();
const result = await executeProcess({
command: shell,
args: ["-c", input.command],
args: ["-c", input.resolvedCommand ?? input.command],
cwd: input.cwd,
env: input.env,
});
@ -495,6 +733,15 @@ async function recordWorkspaceCommandOperation(
stdout: result.stdout,
stderr: result.stderr,
system: result.code === 0 ? input.successMessage ?? null : null,
metadata:
result.stdoutTruncated || result.stderrTruncated
? {
stdoutTruncated: result.stdoutTruncated,
stderrTruncated: result.stderrTruncated,
stdoutBytes: result.stdoutBytes,
stderrBytes: result.stderrBytes,
}
: null,
};
},
});
@ -522,10 +769,12 @@ async function provisionExecutionWorktree(input: {
}) {
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
if (!provisionCommand) return;
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_provision",
command: provisionCommand,
resolvedCommand: resolvedProvisionCommand,
cwd: input.worktreePath,
env: buildWorkspaceCommandEnv({
base: input.base,
@ -542,6 +791,7 @@ async function provisionExecutionWorktree(input: {
worktreePath: input.worktreePath,
branchName: input.branchName,
created: input.created,
resolvedCommand: resolvedProvisionCommand === provisionCommand ? null : resolvedProvisionCommand,
},
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
});
@ -769,6 +1019,12 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
}) {
const warnings: string[] = [];
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
const repoRoot = input.workspace.providerType === "git_worktree" && workspacePath
? await resolveGitRepoRootForWorkspaceCleanup(
workspacePath,
input.projectWorkspace?.cwd ?? null,
)
: null;
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
workspace: input.workspace,
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
@ -784,9 +1040,13 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
for (const command of cleanupCommands) {
try {
const resolvedCommand = repoRoot
? resolveRepoManagedWorkspaceCommand(command, repoRoot)
: command;
await recordWorkspaceCommandOperation(input.recorder, {
phase: "workspace_teardown",
command,
resolvedCommand,
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
env: cleanupEnv,
label: `Execution workspace cleanup command "${command}"`,
@ -795,6 +1055,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
workspacePath,
branchName: input.workspace.branchName,
providerType: input.workspace.providerType,
resolvedCommand: resolvedCommand === command ? null : resolvedCommand,
},
successMessage: `Completed cleanup command "${command}"\n`,
});
@ -804,10 +1065,6 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
}
if (input.workspace.providerType === "git_worktree" && workspacePath) {
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
workspacePath,
input.projectWorkspace?.cwd ?? null,
);
const worktreeExists = await directoryExists(workspacePath);
if (worktreeExists) {
if (!repoRoot) {
@ -1374,7 +1631,11 @@ async function startLocalRuntimeService(input: {
);
}
}
await ensureServerWorkspaceLinksCurrent(serviceCwd, {
onLog: input.onLog,
});
const shell = resolveShell();
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,