Add workspace operation tracking and fix project properties JSX
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const leasedRunIds = new Set<string>();
|
||||
@@ -50,6 +52,68 @@ function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceOperationRecorderDouble() {
|
||||
const operations: Array<{
|
||||
phase: string;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
result: {
|
||||
status?: string;
|
||||
exitCode?: number | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
system?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
}> = [];
|
||||
let executionWorkspaceId: string | null = null;
|
||||
|
||||
const recorder: WorkspaceOperationRecorder = {
|
||||
attachExecutionWorkspaceId: async (nextExecutionWorkspaceId) => {
|
||||
executionWorkspaceId = nextExecutionWorkspaceId;
|
||||
},
|
||||
recordOperation: async (input) => {
|
||||
const result = await input.run();
|
||||
operations.push({
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
metadata: {
|
||||
...(input.metadata ?? {}),
|
||||
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
|
||||
},
|
||||
result,
|
||||
});
|
||||
return {
|
||||
id: `op-${operations.length}`,
|
||||
companyId: "company-1",
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: "run-1",
|
||||
phase: input.phase,
|
||||
command: input.command ?? null,
|
||||
cwd: input.cwd ?? null,
|
||||
status: (result.status ?? "succeeded") as WorkspaceOperation["status"],
|
||||
exitCode: result.exitCode ?? null,
|
||||
logStore: "local_file",
|
||||
logRef: `op-${operations.length}.ndjson`,
|
||||
logBytes: 0,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: result.stdout ?? null,
|
||||
stderrExcerpt: result.stderr ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { recorder, operations };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
Array.from(leasedRunIds).map(async (runId) => {
|
||||
@@ -218,6 +282,64 @@ describe("realizeExecutionWorkspace", () => {
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
|
||||
it("records worktree setup and provision operations when a recorder is provided", 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", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'provisioned\\n'",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add recorder 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: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-540",
|
||||
title: "Record workspace operations",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
expect(operations.map((operation) => operation.phase)).toEqual([
|
||||
"worktree_prepare",
|
||||
"workspace_provision",
|
||||
]);
|
||||
expect(operations[0]?.command).toContain("git worktree add");
|
||||
expect(operations[0]?.metadata).toMatchObject({
|
||||
branchName: "PAP-540-record-workspace-operations",
|
||||
created: true,
|
||||
});
|
||||
expect(operations[1]?.command).toBe("bash ./scripts/provision.sh");
|
||||
});
|
||||
|
||||
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";
|
||||
@@ -389,6 +511,74 @@ describe("realizeExecutionWorkspace", () => {
|
||||
stdout: expect.stringContaining(workspace.branchName!),
|
||||
});
|
||||
});
|
||||
|
||||
it("records teardown and cleanup operations when a recorder is provided", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
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}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-541",
|
||||
title: "Cleanup recorder",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await cleanupExecutionWorkspaceArtifacts({
|
||||
workspace: {
|
||||
id: "execution-workspace-1",
|
||||
cwd: workspace.cwd,
|
||||
providerType: "git_worktree",
|
||||
providerRef: workspace.worktreePath,
|
||||
branchName: workspace.branchName,
|
||||
repoUrl: workspace.repoUrl,
|
||||
baseRef: workspace.repoRef,
|
||||
projectId: workspace.projectId,
|
||||
projectWorkspaceId: workspace.workspaceId,
|
||||
sourceIssueId: "issue-1",
|
||||
metadata: {
|
||||
createdByRuntime: true,
|
||||
},
|
||||
},
|
||||
projectWorkspace: {
|
||||
cwd: repoRoot,
|
||||
cleanupCommand: "printf 'cleanup ok\\n'",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
expect(operations.map((operation) => operation.phase)).toEqual([
|
||||
"workspace_teardown",
|
||||
"worktree_cleanup",
|
||||
"worktree_cleanup",
|
||||
]);
|
||||
expect(operations[0]?.command).toBe("printf 'cleanup ok\\n'");
|
||||
expect(operations[1]?.metadata).toMatchObject({
|
||||
cleanupAction: "worktree_remove",
|
||||
});
|
||||
expect(operations[2]?.metadata).toMatchObject({
|
||||
cleanupAction: "branch_delete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRuntimeServicesForRun", () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
issueService,
|
||||
logActivity,
|
||||
secretService,
|
||||
workspaceOperationService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
@@ -62,6 +63,7 @@ export function agentRoutes(db: Db) {
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
const workspaceOperations = workspaceOperationService(db);
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
|
||||
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
||||
@@ -1560,6 +1562,40 @@ export function agentRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Heartbeat run not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, run.companyId);
|
||||
|
||||
const context = asRecord(run.contextSnapshot);
|
||||
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
|
||||
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
|
||||
res.json(redactCurrentUserValue(operations));
|
||||
});
|
||||
|
||||
router.get("/workspace-operations/:operationId/log", async (req, res) => {
|
||||
const operationId = req.params.operationId as string;
|
||||
const operation = await workspaceOperations.getById(operationId);
|
||||
if (!operation) {
|
||||
res.status(404).json({ error: "Workspace operation not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, operation.companyId);
|
||||
|
||||
const offset = Number(req.query.offset ?? 0);
|
||||
const limitBytes = Number(req.query.limitBytes ?? 256000);
|
||||
const result = await workspaceOperations.readLog(operationId, {
|
||||
offset: Number.isFinite(offset) ? offset : 0,
|
||||
limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:issueId/live-runs", async (req, res) => {
|
||||
const rawId = req.params.issueId as string;
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { executionWorkspaceService, logActivity } from "../services/index.js";
|
||||
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||
import {
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
@@ -17,6 +17,7 @@ const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
||||
export function executionWorkspaceRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = executionWorkspaceService(db);
|
||||
const workspaceOperationsSvc = workspaceOperationService(db);
|
||||
|
||||
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -121,6 +122,10 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
workspace: existing,
|
||||
projectWorkspace,
|
||||
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
recorder: workspaceOperationsSvc.createRecorder({
|
||||
companyId: existing.companyId,
|
||||
executionWorkspaceId: existing.id,
|
||||
}),
|
||||
});
|
||||
cleanupWarnings = cleanupResult.warnings;
|
||||
const cleanupPatch: Record<string, unknown> = {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
@@ -705,6 +706,7 @@ export function heartbeatService(db: Db) {
|
||||
const secretsSvc = secretService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const workspaceOperationsSvc = workspaceOperationService(db);
|
||||
const activeRunExecutions = new Set<string>();
|
||||
const budgetHooks = {
|
||||
cancelWorkForScope: cancelBudgetScopeWork,
|
||||
@@ -1732,6 +1734,13 @@ export function heartbeatService(db: Db) {
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||
});
|
||||
const executionWorkspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: resolvedWorkspace.cwd,
|
||||
@@ -1748,9 +1757,8 @@ export function heartbeatService(db: Db) {
|
||||
name: agent.name,
|
||||
companyId: agent.companyId,
|
||||
},
|
||||
recorder: workspaceOperationRecorder,
|
||||
});
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
|
||||
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
const shouldReuseExisting =
|
||||
@@ -1804,12 +1812,23 @@ export function heartbeatService(db: Db) {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
await workspaceOperationRecorder.attachExecutionWorkspaceId(persistedExecutionWorkspace?.id ?? null);
|
||||
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
||||
await issuesSvc.update(issueId, {
|
||||
executionWorkspaceId: persistedExecutionWorkspace.id,
|
||||
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
|
||||
});
|
||||
}
|
||||
if (persistedExecutionWorkspace) {
|
||||
context.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
contextSnapshot: context,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
|
||||
agentId: agent.id,
|
||||
previousSessionParams,
|
||||
|
||||
@@ -19,6 +19,7 @@ export { accessService } from "./access.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workspaceOperationService } from "./workspace-operations.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
|
||||
156
server/src/services/workspace-operation-log-store.ts
Normal file
156
server/src/services/workspace-operation-log-store.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
export type WorkspaceOperationLogStoreType = "local_file";
|
||||
|
||||
export interface WorkspaceOperationLogHandle {
|
||||
store: WorkspaceOperationLogStoreType;
|
||||
logRef: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogReadOptions {
|
||||
offset?: number;
|
||||
limitBytes?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogReadResult {
|
||||
content: string;
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogFinalizeSummary {
|
||||
bytes: number;
|
||||
sha256?: string;
|
||||
compressed: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogStore {
|
||||
begin(input: { companyId: string; operationId: string }): Promise<WorkspaceOperationLogHandle>;
|
||||
append(
|
||||
handle: WorkspaceOperationLogHandle,
|
||||
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
|
||||
): Promise<void>;
|
||||
finalize(handle: WorkspaceOperationLogHandle): Promise<WorkspaceOperationLogFinalizeSummary>;
|
||||
read(handle: WorkspaceOperationLogHandle, opts?: WorkspaceOperationLogReadOptions): Promise<WorkspaceOperationLogReadResult>;
|
||||
}
|
||||
|
||||
function safeSegments(...segments: string[]) {
|
||||
return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));
|
||||
}
|
||||
|
||||
function resolveWithin(basePath: string, relativePath: string) {
|
||||
const resolved = path.resolve(basePath, relativePath);
|
||||
const base = path.resolve(basePath) + path.sep;
|
||||
if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {
|
||||
throw new Error("Invalid log path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function createLocalFileWorkspaceOperationLogStore(basePath: string): WorkspaceOperationLogStore {
|
||||
async function ensureDir(relativeDir: string) {
|
||||
const dir = resolveWithin(basePath, relativeDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function readFileRange(filePath: string, offset: number, limitBytes: number): Promise<WorkspaceOperationLogReadResult> {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat) throw notFound("Workspace operation log not found");
|
||||
|
||||
const start = Math.max(0, Math.min(offset, stat.size));
|
||||
const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));
|
||||
|
||||
if (start > end) {
|
||||
return { content: "", nextOffset: start };
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
stream.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve());
|
||||
});
|
||||
|
||||
const content = Buffer.concat(chunks).toString("utf8");
|
||||
const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
|
||||
return { content, nextOffset };
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async begin(input) {
|
||||
const [companyId] = safeSegments(input.companyId);
|
||||
const operationId = safeSegments(input.operationId)[0]!;
|
||||
const relDir = companyId;
|
||||
const relPath = path.join(relDir, `${operationId}.ndjson`);
|
||||
await ensureDir(relDir);
|
||||
|
||||
const absPath = resolveWithin(basePath, relPath);
|
||||
await fs.writeFile(absPath, "", "utf8");
|
||||
|
||||
return { store: "local_file", logRef: relPath };
|
||||
},
|
||||
|
||||
async append(handle, event) {
|
||||
if (handle.store !== "local_file") return;
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const line = JSON.stringify({
|
||||
ts: event.ts,
|
||||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
if (handle.store !== "local_file") {
|
||||
return { bytes: 0, compressed: false };
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const stat = await fs.stat(absPath).catch(() => null);
|
||||
if (!stat) throw notFound("Workspace operation log not found");
|
||||
|
||||
const hash = await sha256File(absPath);
|
||||
return {
|
||||
bytes: stat.size,
|
||||
sha256: hash,
|
||||
compressed: false,
|
||||
};
|
||||
},
|
||||
|
||||
async read(handle, opts) {
|
||||
if (handle.store !== "local_file") {
|
||||
throw notFound("Workspace operation log not found");
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limitBytes = opts?.limitBytes ?? 256_000;
|
||||
return readFileRange(absPath, offset, limitBytes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let cachedStore: WorkspaceOperationLogStore | null = null;
|
||||
|
||||
export function getWorkspaceOperationLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH
|
||||
?? path.resolve(resolvePaperclipInstanceRoot(), "data", "workspace-operation-logs");
|
||||
cachedStore = createLocalFileWorkspaceOperationLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
250
server/src/services/workspace-operations.ts
Normal file
250
server/src/services/workspace-operations.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { workspaceOperations } from "@paperclipai/db";
|
||||
import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus } from "@paperclipai/shared";
|
||||
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
|
||||
import { notFound } from "../errors.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
|
||||
|
||||
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
|
||||
|
||||
function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
heartbeatRunId: row.heartbeatRunId ?? null,
|
||||
phase: row.phase as WorkspaceOperationPhase,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
status: row.status as WorkspaceOperationStatus,
|
||||
exitCode: row.exitCode ?? null,
|
||||
logStore: row.logStore ?? null,
|
||||
logRef: row.logRef ?? null,
|
||||
logBytes: row.logBytes ?? null,
|
||||
logSha256: row.logSha256 ?? null,
|
||||
logCompressed: row.logCompressed,
|
||||
stdoutExcerpt: row.stdoutExcerpt ?? null,
|
||||
stderrExcerpt: row.stderrExcerpt ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendExcerpt(current: string, chunk: string) {
|
||||
return `${current}${chunk}`.slice(-4096);
|
||||
}
|
||||
|
||||
function combineMetadata(
|
||||
base: Record<string, unknown> | null | undefined,
|
||||
patch: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (!base && !patch) return null;
|
||||
return {
|
||||
...(base ?? {}),
|
||||
...(patch ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationRecorder {
|
||||
attachExecutionWorkspaceId(executionWorkspaceId: string | null): Promise<void>;
|
||||
recordOperation(input: {
|
||||
phase: WorkspaceOperationPhase;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
run: () => Promise<{
|
||||
status?: WorkspaceOperationStatus;
|
||||
exitCode?: number | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
system?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}>;
|
||||
}): Promise<WorkspaceOperation>;
|
||||
}
|
||||
|
||||
export function workspaceOperationService(db: Db) {
|
||||
const logStore = getWorkspaceOperationLogStore();
|
||||
|
||||
async function getById(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(eq(workspaceOperations.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toWorkspaceOperation(row) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
getById,
|
||||
|
||||
createRecorder(input: {
|
||||
companyId: string;
|
||||
heartbeatRunId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
}): WorkspaceOperationRecorder {
|
||||
let executionWorkspaceId = input.executionWorkspaceId ?? null;
|
||||
const createdIds: string[] = [];
|
||||
|
||||
return {
|
||||
async attachExecutionWorkspaceId(nextExecutionWorkspaceId) {
|
||||
executionWorkspaceId = nextExecutionWorkspaceId ?? null;
|
||||
if (!executionWorkspaceId || createdIds.length === 0) return;
|
||||
await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(workspaceOperations.id, createdIds));
|
||||
},
|
||||
|
||||
async recordOperation(recordInput) {
|
||||
const startedAt = new Date();
|
||||
const id = randomUUID();
|
||||
const handle = await logStore.begin({
|
||||
companyId: input.companyId,
|
||||
operationId: id,
|
||||
});
|
||||
|
||||
let stdoutExcerpt = "";
|
||||
let stderrExcerpt = "";
|
||||
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
|
||||
if (!chunk) return;
|
||||
const sanitizedChunk = redactCurrentUserText(chunk);
|
||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||
await logStore.append(handle, {
|
||||
stream,
|
||||
chunk: sanitizedChunk,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
phase: recordInput.phase,
|
||||
command: recordInput.command ?? null,
|
||||
cwd: recordInput.cwd ?? null,
|
||||
status: "running",
|
||||
logStore: handle.store,
|
||||
logRef: handle.logRef,
|
||||
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
|
||||
startedAt,
|
||||
});
|
||||
createdIds.push(id);
|
||||
|
||||
try {
|
||||
const result = await recordInput.run();
|
||||
await append("system", result.system ?? null);
|
||||
await append("stdout", result.stdout ?? null);
|
||||
await append("stderr", result.stderr ?? null);
|
||||
const finalized = await logStore.finalize(handle);
|
||||
const finishedAt = new Date();
|
||||
const row = await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
status: result.status ?? "succeeded",
|
||||
exitCode: result.exitCode ?? null,
|
||||
stdoutExcerpt: stdoutExcerpt || null,
|
||||
stderrExcerpt: stderrExcerpt || null,
|
||||
logBytes: finalized.bytes,
|
||||
logSha256: finalized.sha256,
|
||||
logCompressed: finalized.compressed,
|
||||
metadata: redactCurrentUserValue(
|
||||
combineMetadata(recordInput.metadata, result.metadata),
|
||||
) as Record<string, unknown> | null,
|
||||
finishedAt,
|
||||
updatedAt: finishedAt,
|
||||
})
|
||||
.where(eq(workspaceOperations.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Workspace operation not found");
|
||||
return toWorkspaceOperation(row);
|
||||
} catch (error) {
|
||||
await append("stderr", error instanceof Error ? error.message : String(error));
|
||||
const finalized = await logStore.finalize(handle).catch(() => null);
|
||||
const finishedAt = new Date();
|
||||
await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
status: "failed",
|
||||
stdoutExcerpt: stdoutExcerpt || null,
|
||||
stderrExcerpt: stderrExcerpt || null,
|
||||
logBytes: finalized?.bytes ?? null,
|
||||
logSha256: finalized?.sha256 ?? null,
|
||||
logCompressed: finalized?.compressed ?? false,
|
||||
finishedAt,
|
||||
updatedAt: finishedAt,
|
||||
})
|
||||
.where(eq(workspaceOperations.id, id));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
listForRun: async (runId: string, executionWorkspaceId?: string | null) => {
|
||||
const conditions = [eq(workspaceOperations.heartbeatRunId, runId)];
|
||||
if (executionWorkspaceId) {
|
||||
const cleanupCondition = and(
|
||||
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId)!,
|
||||
isNull(workspaceOperations.heartbeatRunId),
|
||||
)!;
|
||||
if (cleanupCondition) conditions.push(cleanupCondition);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(conditions.length === 1 ? conditions[0]! : or(...conditions)!)
|
||||
.orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt), asc(workspaceOperations.id));
|
||||
|
||||
return rows.map(toWorkspaceOperation);
|
||||
},
|
||||
|
||||
listForExecutionWorkspace: async (executionWorkspaceId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId))
|
||||
.orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.createdAt));
|
||||
return rows.map(toWorkspaceOperation);
|
||||
},
|
||||
|
||||
readLog: async (operationId: string, opts?: { offset?: number; limitBytes?: number }) => {
|
||||
const operation = await getById(operationId);
|
||||
if (!operation) throw notFound("Workspace operation not found");
|
||||
if (!operation.logStore || !operation.logRef) throw notFound("Workspace operation log not found");
|
||||
|
||||
const result = await logStore.read(
|
||||
{
|
||||
store: operation.logStore as "local_file",
|
||||
logRef: operation.logRef,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
return {
|
||||
operationId,
|
||||
store: operation.logStore,
|
||||
logRef: operation.logRef,
|
||||
...result,
|
||||
content: redactCurrentUserText(result.content),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toWorkspaceOperation };
|
||||
@@ -10,6 +10,7 @@ import { workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
||||
import { resolveHomeAwarePath } from "../home-paths.js";
|
||||
import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
|
||||
|
||||
export interface ExecutionWorkspaceInput {
|
||||
baseCwd: string;
|
||||
@@ -221,12 +222,23 @@ function resolveConfiguredPath(value: string, baseDir: string): string {
|
||||
return path.resolve(baseDir, value);
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
function formatCommandForDisplay(command: string, args: string[]) {
|
||||
return [command, ...args]
|
||||
.map((part) => (/^[A-Za-z0-9_./:-]+$/.test(part) ? part : JSON.stringify(part)))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
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) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
const child = spawn(input.command, input.args, {
|
||||
cwd: input.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
env: input.env ?? process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -239,6 +251,15 @@ async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
const proc = await executeProcess({
|
||||
command: "git",
|
||||
args,
|
||||
cwd,
|
||||
});
|
||||
if (proc.code !== 0) {
|
||||
throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`);
|
||||
}
|
||||
@@ -307,22 +328,11 @@ async function runWorkspaceCommand(input: {
|
||||
label: string;
|
||||
}) {
|
||||
const shell = process.env.SHELL?.trim() || "/bin/sh";
|
||||
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
|
||||
const child = spawn(shell, ["-c", input.command], {
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
const proc = await executeProcess({
|
||||
command: shell,
|
||||
args: ["-c", input.command],
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
if (proc.code === 0) return;
|
||||
|
||||
@@ -334,6 +344,115 @@ async function runWorkspaceCommand(input: {
|
||||
);
|
||||
}
|
||||
|
||||
async function recordGitOperation(
|
||||
recorder: WorkspaceOperationRecorder | null | undefined,
|
||||
input: {
|
||||
phase: "worktree_prepare" | "worktree_cleanup";
|
||||
args: string[];
|
||||
cwd: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
successMessage?: string | null;
|
||||
failureLabel?: string | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!recorder) {
|
||||
return runGit(input.args, input.cwd);
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
await recorder.recordOperation({
|
||||
phase: input.phase,
|
||||
command: formatCommandForDisplay("git", input.args),
|
||||
cwd: input.cwd,
|
||||
metadata: input.metadata ?? null,
|
||||
run: async () => {
|
||||
const result = await executeProcess({
|
||||
command: "git",
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
code = result.code;
|
||||
return {
|
||||
status: result.code === 0 ? "succeeded" : "failed",
|
||||
exitCode: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (code !== 0) {
|
||||
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
details.length > 0
|
||||
? `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed: ${details}`
|
||||
: `${input.failureLabel ?? `git ${input.args.join(" ")}`} failed with exit code ${code ?? -1}`,
|
||||
);
|
||||
}
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function recordWorkspaceCommandOperation(
|
||||
recorder: WorkspaceOperationRecorder | null | undefined,
|
||||
input: {
|
||||
phase: "workspace_provision" | "workspace_teardown";
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
label: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
successMessage?: string | null;
|
||||
},
|
||||
) {
|
||||
if (!recorder) {
|
||||
await runWorkspaceCommand(input);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
await recorder.recordOperation({
|
||||
phase: input.phase,
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
metadata: input.metadata ?? null,
|
||||
run: async () => {
|
||||
const shell = process.env.SHELL?.trim() || "/bin/sh";
|
||||
const result = await executeProcess({
|
||||
command: shell,
|
||||
args: ["-c", input.command],
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
stdout = result.stdout;
|
||||
stderr = result.stderr;
|
||||
code = result.code;
|
||||
return {
|
||||
status: result.code === 0 ? "succeeded" : "failed",
|
||||
exitCode: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
system: result.code === 0 ? input.successMessage ?? null : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (code === 0) return;
|
||||
|
||||
const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
||||
throw new Error(
|
||||
details.length > 0
|
||||
? `${input.label} failed: ${details}`
|
||||
: `${input.label} failed with exit code ${code ?? -1}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function provisionExecutionWorktree(input: {
|
||||
strategy: Record<string, unknown>;
|
||||
base: ExecutionWorkspaceInput;
|
||||
@@ -343,11 +462,13 @@ async function provisionExecutionWorktree(input: {
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
created: boolean;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}) {
|
||||
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
|
||||
if (!provisionCommand) return;
|
||||
|
||||
await runWorkspaceCommand({
|
||||
await recordWorkspaceCommandOperation(input.recorder, {
|
||||
phase: "workspace_provision",
|
||||
command: provisionCommand,
|
||||
cwd: input.worktreePath,
|
||||
env: buildWorkspaceCommandEnv({
|
||||
@@ -360,6 +481,13 @@ async function provisionExecutionWorktree(input: {
|
||||
created: input.created,
|
||||
}),
|
||||
label: `Execution workspace provision command "${provisionCommand}"`,
|
||||
metadata: {
|
||||
repoRoot: input.repoRoot,
|
||||
worktreePath: input.worktreePath,
|
||||
branchName: input.branchName,
|
||||
created: input.created,
|
||||
},
|
||||
successMessage: `Provisioned workspace at ${input.worktreePath}\n`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,6 +545,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
config: Record<string, unknown>;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<RealizedExecutionWorkspace> {
|
||||
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
||||
const strategyType = asString(rawStrategy.type, "project_primary");
|
||||
@@ -454,6 +583,25 @@ export async function realizeExecutionWorkspace(input: {
|
||||
if (existingWorktree) {
|
||||
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
|
||||
if (existingGitDir) {
|
||||
if (input.recorder) {
|
||||
await input.recorder.recordOperation({
|
||||
phase: "worktree_prepare",
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reused: true,
|
||||
},
|
||||
run: async () => ({
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
system: `Reused existing git worktree at ${worktreePath}\n`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
@@ -463,6 +611,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
return {
|
||||
...input.base,
|
||||
@@ -478,12 +627,39 @@ export async function realizeExecutionWorkspace(input: {
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(["worktree", "add", "-b", branchName, worktreePath, baseRef], repoRoot);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: true,
|
||||
},
|
||||
successMessage: `Created git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!gitErrorIncludes(error, "already exists")) {
|
||||
throw error;
|
||||
}
|
||||
await runGit(["worktree", "add", worktreePath, branchName], repoRoot);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reusedExistingBranch: true,
|
||||
},
|
||||
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
@@ -494,6 +670,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: true,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -526,6 +703,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
cleanupCommand: string | null;
|
||||
} | null;
|
||||
teardownCommand?: string | null;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}) {
|
||||
const warnings: string[] = [];
|
||||
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
|
||||
@@ -543,11 +721,19 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
|
||||
for (const command of cleanupCommands) {
|
||||
try {
|
||||
await runWorkspaceCommand({
|
||||
await recordWorkspaceCommandOperation(input.recorder, {
|
||||
phase: "workspace_teardown",
|
||||
command,
|
||||
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
||||
env: cleanupEnv,
|
||||
label: `Execution workspace cleanup command "${command}"`,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
providerType: input.workspace.providerType,
|
||||
},
|
||||
successMessage: `Completed cleanup command "${command}"\n`,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
@@ -565,7 +751,19 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
|
||||
} else {
|
||||
try {
|
||||
await runGit(["worktree", "remove", "--force", workspacePath], repoRoot);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["worktree", "remove", "--force", workspacePath],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "worktree_remove",
|
||||
},
|
||||
successMessage: `Removed git worktree ${workspacePath}\n`,
|
||||
failureLabel: `git worktree remove ${workspacePath}`,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -576,7 +774,19 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
warnings.push(`Could not resolve git repo root to delete branch "${input.workspace.branchName}".`);
|
||||
} else {
|
||||
try {
|
||||
await runGit(["branch", "-d", input.workspace.branchName], repoRoot);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_cleanup",
|
||||
args: ["branch", "-d", input.workspace.branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath,
|
||||
branchName: input.workspace.branchName,
|
||||
cleanupAction: "branch_delete",
|
||||
},
|
||||
successMessage: `Deleted branch ${input.workspace.branchName}\n`,
|
||||
failureLabel: `git branch -d ${input.workspace.branchName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`Skipped deleting branch "${input.workspace.branchName}": ${message}`);
|
||||
@@ -590,6 +800,22 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
warnings.push(`Refusing to remove shared project workspace "${workspacePath}".`);
|
||||
} else {
|
||||
await fs.rm(resolvedWorkspacePath, { recursive: true, force: true });
|
||||
if (input.recorder) {
|
||||
await input.recorder.recordOperation({
|
||||
phase: "workspace_teardown",
|
||||
cwd: projectWorkspaceCwd ?? process.cwd(),
|
||||
metadata: {
|
||||
workspaceId: input.workspace.id,
|
||||
workspacePath: resolvedWorkspacePath,
|
||||
cleanupAction: "remove_local_fs",
|
||||
},
|
||||
run: async () => ({
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
system: `Removed local workspace directory ${resolvedWorkspacePath}\n`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user