Fix execution workspace runtime lifecycle
This commit is contained in:
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
19
server/src/__tests__/app-hmr-port.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveViteHmrPort } from "../app.ts";
|
||||||
|
|
||||||
|
describe("resolveViteHmrPort", () => {
|
||||||
|
it("uses serverPort + 10000 when the result stays in range", () => {
|
||||||
|
expect(resolveViteHmrPort(3100)).toBe(13_100);
|
||||||
|
expect(resolveViteHmrPort(55_535)).toBe(65_535);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back below the server port when adding 10000 would overflow", () => {
|
||||||
|
expect(resolveViteHmrPort(55_536)).toBe(45_536);
|
||||||
|
expect(resolveViteHmrPort(63_000)).toBe(53_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a privileged or invalid port", () => {
|
||||||
|
expect(resolveViteHmrPort(65_535)).toBe(55_535);
|
||||||
|
expect(resolveViteHmrPort(9_000)).toBe(19_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
import {
|
import {
|
||||||
|
prioritizeProjectWorkspaceCandidatesForRun,
|
||||||
resolveRuntimeSessionParamsForWorkspace,
|
resolveRuntimeSessionParamsForWorkspace,
|
||||||
shouldResetTaskSessionForWake,
|
shouldResetTaskSessionForWake,
|
||||||
type ResolvedWorkspaceForRun,
|
type ResolvedWorkspaceForRun,
|
||||||
@@ -141,3 +142,39 @@ describe("shouldResetTaskSessionForWake", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
|
||||||
|
it("moves the explicitly selected workspace to the front", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "workspace-1", cwd: "/tmp/one" },
|
||||||
|
{ id: "workspace-2", cwd: "/tmp/two" },
|
||||||
|
{ id: "workspace-3", cwd: "/tmp/three" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-2").map((row) => row.id),
|
||||||
|
).toEqual(["workspace-2", "workspace-1", "workspace-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original order when no preferred workspace is selected", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "workspace-1" },
|
||||||
|
{ id: "workspace-2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
prioritizeProjectWorkspaceCandidatesForRun(rows, null).map((row) => row.id),
|
||||||
|
).toEqual(["workspace-1", "workspace-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original order when the selected workspace is missing", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "workspace-1" },
|
||||||
|
{ id: "workspace-2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
prioritizeProjectWorkspaceCandidatesForRun(rows, "workspace-9").map((row) => row.id),
|
||||||
|
).toEqual(["workspace-1", "workspace-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
ensureRuntimeServicesForRun,
|
ensureRuntimeServicesForRun,
|
||||||
normalizeAdapterManagedRuntimeServices,
|
normalizeAdapterManagedRuntimeServices,
|
||||||
realizeExecutionWorkspace,
|
realizeExecutionWorkspace,
|
||||||
@@ -55,6 +56,10 @@ afterEach(async () => {
|
|||||||
leasedRunIds.delete(runId);
|
leasedRunIds.delete(runId);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
delete process.env.PAPERCLIP_CONFIG;
|
||||||
|
delete process.env.PAPERCLIP_HOME;
|
||||||
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("realizeExecutionWorkspace", () => {
|
describe("realizeExecutionWorkspace", () => {
|
||||||
@@ -211,6 +216,68 @@ describe("realizeExecutionWorkspace", () => {
|
|||||||
|
|
||||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a created git worktree and branch during cleanup", async () => {
|
||||||
|
const repoRoot = await createTempRepo();
|
||||||
|
|
||||||
|
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-449",
|
||||||
|
title: "Cleanup workspace",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = 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: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cleanup.cleaned).toBe(true);
|
||||||
|
expect(cleanup.warnings).toEqual([]);
|
||||||
|
await expect(fs.stat(workspace.cwd)).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
execFileAsync("git", ["branch", "--list", workspace.branchName!], { cwd: repoRoot }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ensureRuntimeServicesForRun", () => {
|
describe("ensureRuntimeServicesForRun", () => {
|
||||||
@@ -312,6 +379,84 @@ describe("ensureRuntimeServicesForRun", () => {
|
|||||||
expect(third[0]?.reused).toBe(false);
|
expect(third[0]?.reused).toBe(false);
|
||||||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not leak parent Paperclip instance env into runtime service commands", async () => {
|
||||||
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-"));
|
||||||
|
const workspace = buildWorkspace(workspaceRoot);
|
||||||
|
const envCapturePath = path.join(workspaceRoot, "captured-env.json");
|
||||||
|
const serviceCommand = [
|
||||||
|
"node -e",
|
||||||
|
JSON.stringify(
|
||||||
|
[
|
||||||
|
"const fs = require('node:fs');",
|
||||||
|
`fs.writeFileSync(${JSON.stringify(envCapturePath)}, JSON.stringify({`,
|
||||||
|
"paperclipConfig: process.env.PAPERCLIP_CONFIG ?? null,",
|
||||||
|
"paperclipHome: process.env.PAPERCLIP_HOME ?? null,",
|
||||||
|
"paperclipInstanceId: process.env.PAPERCLIP_INSTANCE_ID ?? null,",
|
||||||
|
"databaseUrl: process.env.DATABASE_URL ?? null,",
|
||||||
|
"customEnv: process.env.RUNTIME_CUSTOM_ENV ?? null,",
|
||||||
|
"port: process.env.PORT ?? null,",
|
||||||
|
"}));",
|
||||||
|
"require('node:http').createServer((req, res) => res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1');",
|
||||||
|
].join(" "),
|
||||||
|
),
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
process.env.PAPERCLIP_CONFIG = "/tmp/base-paperclip-config.json";
|
||||||
|
process.env.PAPERCLIP_HOME = "/tmp/base-paperclip-home";
|
||||||
|
process.env.PAPERCLIP_INSTANCE_ID = "base-instance";
|
||||||
|
process.env.DATABASE_URL = "postgres://shared-db.example.com/paperclip";
|
||||||
|
|
||||||
|
const runId = "run-env";
|
||||||
|
leasedRunIds.add(runId);
|
||||||
|
|
||||||
|
const services = await ensureRuntimeServicesForRun({
|
||||||
|
runId,
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: null,
|
||||||
|
workspace,
|
||||||
|
executionWorkspaceId: "execution-workspace-1",
|
||||||
|
config: {
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "web",
|
||||||
|
command: serviceCommand,
|
||||||
|
port: { type: "auto" },
|
||||||
|
readiness: {
|
||||||
|
type: "http",
|
||||||
|
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||||
|
timeoutSec: 10,
|
||||||
|
intervalMs: 100,
|
||||||
|
},
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseScope: "execution_workspace",
|
||||||
|
stopPolicy: {
|
||||||
|
type: "on_run_finish",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adapterEnv: {
|
||||||
|
RUNTIME_CUSTOM_ENV: "from-adapter",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(services).toHaveLength(1);
|
||||||
|
const captured = JSON.parse(await fs.readFile(envCapturePath, "utf8")) as Record<string, string | null>;
|
||||||
|
expect(captured.paperclipConfig).toBeNull();
|
||||||
|
expect(captured.paperclipHome).toBeNull();
|
||||||
|
expect(captured.paperclipInstanceId).toBeNull();
|
||||||
|
expect(captured.databaseUrl).toBeNull();
|
||||||
|
expect(captured.customEnv).toBe("from-adapter");
|
||||||
|
expect(captured.port).toMatch(/^\d+$/);
|
||||||
|
expect(services[0]?.executionWorkspaceId).toBe("execution-workspace-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||||
@@ -374,6 +519,7 @@ describe("normalizeAdapterManagedRuntimeServices", () => {
|
|||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
projectWorkspaceId: "workspace-1",
|
projectWorkspaceId: "workspace-1",
|
||||||
|
executionWorkspaceId: null,
|
||||||
issueId: "issue-1",
|
issueId: "issue-1",
|
||||||
serviceName: "preview",
|
serviceName: "preview",
|
||||||
provider: "adapter_managed",
|
provider: "adapter_managed",
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
|||||||
|
|
||||||
type UiMode = "none" | "static" | "vite-dev";
|
type UiMode = "none" | "static" | "vite-dev";
|
||||||
|
|
||||||
|
export function resolveViteHmrPort(serverPort: number): number {
|
||||||
|
if (serverPort <= 55_535) {
|
||||||
|
return serverPort + 10_000;
|
||||||
|
}
|
||||||
|
return Math.max(1_024, serverPort - 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createApp(
|
export async function createApp(
|
||||||
db: Db,
|
db: Db,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -150,7 +157,7 @@ export async function createApp(
|
|||||||
|
|
||||||
if (opts.uiMode === "vite-dev") {
|
if (opts.uiMode === "vite-dev") {
|
||||||
const uiRoot = path.resolve(__dirname, "../../ui");
|
const uiRoot = path.resolve(__dirname, "../../ui");
|
||||||
const hmrPort = opts.serverPort + 10000;
|
const hmrPort = resolveViteHmrPort(opts.serverPort);
|
||||||
const { createServer: createViteServer } = await import("vite");
|
const { createServer: createViteServer } = await import("vite");
|
||||||
const vite = await createViteServer({
|
const vite = await createViteServer({
|
||||||
root: uiRoot,
|
root: uiRoot,
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
|
||||||
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { executionWorkspaceService, logActivity } from "../services/index.js";
|
import { executionWorkspaceService, logActivity } from "../services/index.js";
|
||||||
|
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
||||||
|
import {
|
||||||
|
cleanupExecutionWorkspaceArtifacts,
|
||||||
|
stopRuntimeServicesForExecutionWorkspace,
|
||||||
|
} from "../services/workspace-runtime.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
|
||||||
|
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
||||||
|
|
||||||
export function executionWorkspaceRoutes(db: Db) {
|
export function executionWorkspaceRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = executionWorkspaceService(db);
|
const svc = executionWorkspaceService(db);
|
||||||
@@ -41,10 +50,72 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
assertCompanyAccess(req, existing.companyId);
|
||||||
const workspace = await svc.update(id, {
|
const patch: Record<string, unknown> = {
|
||||||
...req.body,
|
...req.body,
|
||||||
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
|
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
|
||||||
|
};
|
||||||
|
let cleanupWarnings: string[] = [];
|
||||||
|
|
||||||
|
if (req.body.status === "archived" && existing.status !== "archived") {
|
||||||
|
const linkedIssues = await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
status: issues.status,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id)));
|
||||||
|
const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status));
|
||||||
|
|
||||||
|
if (activeLinkedIssues.length > 0) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopRuntimeServicesForExecutionWorkspace({
|
||||||
|
db,
|
||||||
|
executionWorkspaceId: existing.id,
|
||||||
|
workspaceCwd: existing.cwd,
|
||||||
|
});
|
||||||
|
const projectWorkspace = existing.projectWorkspaceId
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
cwd: projectWorkspaces.cwd,
|
||||||
|
cleanupCommand: projectWorkspaces.cleanupCommand,
|
||||||
|
})
|
||||||
|
.from(projectWorkspaces)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectWorkspaces.id, existing.projectWorkspaceId),
|
||||||
|
eq(projectWorkspaces.companyId, existing.companyId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
: null;
|
||||||
|
const projectPolicy = existing.projectId
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId)))
|
||||||
|
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||||
|
: null;
|
||||||
|
const cleanupResult = await cleanupExecutionWorkspaceArtifacts({
|
||||||
|
workspace: existing,
|
||||||
|
projectWorkspace,
|
||||||
|
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||||
|
});
|
||||||
|
cleanupWarnings = cleanupResult.warnings;
|
||||||
|
patch.closedAt = new Date();
|
||||||
|
patch.cleanupReason = cleanupWarnings.length > 0 ? cleanupWarnings.join(" | ") : null;
|
||||||
|
if (!cleanupResult.cleaned) {
|
||||||
|
patch.status = "cleanup_failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await svc.update(id, patch);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
res.status(404).json({ error: "Execution workspace not found" });
|
res.status(404).json({ error: "Execution workspace not found" });
|
||||||
return;
|
return;
|
||||||
@@ -59,7 +130,10 @@ export function executionWorkspaceRoutes(db: Db) {
|
|||||||
action: "execution_workspace.updated",
|
action: "execution_workspace.updated",
|
||||||
entityType: "execution_workspace",
|
entityType: "execution_workspace",
|
||||||
entityId: workspace.id,
|
entityId: workspace.id,
|
||||||
details: { changedKeys: Object.keys(req.body).sort() },
|
details: {
|
||||||
|
changedKeys: Object.keys(req.body).sort(),
|
||||||
|
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
res.json(workspace);
|
res.json(workspace);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ export type ResolvedWorkspaceForRun = {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProjectWorkspaceCandidate = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function prioritizeProjectWorkspaceCandidatesForRun<T extends ProjectWorkspaceCandidate>(
|
||||||
|
rows: T[],
|
||||||
|
preferredWorkspaceId: string | null | undefined,
|
||||||
|
): T[] {
|
||||||
|
if (!preferredWorkspaceId) return rows;
|
||||||
|
const preferredIndex = rows.findIndex((row) => row.id === preferredWorkspaceId);
|
||||||
|
if (preferredIndex <= 0) return rows;
|
||||||
|
return [rows[preferredIndex]!, ...rows.slice(0, preferredIndex), ...rows.slice(preferredIndex + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
function readNonEmptyString(value: unknown): string | null {
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
@@ -537,18 +551,25 @@ export function heartbeatService(db: Db) {
|
|||||||
): Promise<ResolvedWorkspaceForRun> {
|
): Promise<ResolvedWorkspaceForRun> {
|
||||||
const issueId = readNonEmptyString(context.issueId);
|
const issueId = readNonEmptyString(context.issueId);
|
||||||
const contextProjectId = readNonEmptyString(context.projectId);
|
const contextProjectId = readNonEmptyString(context.projectId);
|
||||||
const issueProjectId = issueId
|
const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId);
|
||||||
|
const issueProjectRef = issueId
|
||||||
? await db
|
? await db
|
||||||
.select({ projectId: issues.projectId })
|
.select({
|
||||||
|
projectId: issues.projectId,
|
||||||
|
projectWorkspaceId: issues.projectWorkspaceId,
|
||||||
|
})
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||||
.then((rows) => rows[0]?.projectId ?? null)
|
.then((rows) => rows[0] ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const issueProjectId = issueProjectRef?.projectId ?? null;
|
||||||
|
const preferredProjectWorkspaceId =
|
||||||
|
issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null;
|
||||||
const resolvedProjectId = issueProjectId ?? contextProjectId;
|
const resolvedProjectId = issueProjectId ?? contextProjectId;
|
||||||
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
|
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
|
||||||
const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null;
|
const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null;
|
||||||
|
|
||||||
const projectWorkspaceRows = workspaceProjectId
|
const unorderedProjectWorkspaceRows = workspaceProjectId
|
||||||
? await db
|
? await db
|
||||||
.select()
|
.select()
|
||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
@@ -560,6 +581,10 @@ export function heartbeatService(db: Db) {
|
|||||||
)
|
)
|
||||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||||
: [];
|
: [];
|
||||||
|
const projectWorkspaceRows = prioritizeProjectWorkspaceCandidatesForRun(
|
||||||
|
unorderedProjectWorkspaceRows,
|
||||||
|
preferredProjectWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
const workspaceHints = projectWorkspaceRows.map((workspace) => ({
|
const workspaceHints = projectWorkspaceRows.map((workspace) => ({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
@@ -569,11 +594,22 @@ export function heartbeatService(db: Db) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (projectWorkspaceRows.length > 0) {
|
if (projectWorkspaceRows.length > 0) {
|
||||||
|
const preferredWorkspace = preferredProjectWorkspaceId
|
||||||
|
? projectWorkspaceRows.find((workspace) => workspace.id === preferredProjectWorkspaceId) ?? null
|
||||||
|
: null;
|
||||||
const missingProjectCwds: string[] = [];
|
const missingProjectCwds: string[] = [];
|
||||||
let hasConfiguredProjectCwd = false;
|
let hasConfiguredProjectCwd = false;
|
||||||
|
let preferredWorkspaceWarning: string | null = null;
|
||||||
|
if (preferredProjectWorkspaceId && !preferredWorkspace) {
|
||||||
|
preferredWorkspaceWarning =
|
||||||
|
`Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`;
|
||||||
|
}
|
||||||
for (const workspace of projectWorkspaceRows) {
|
for (const workspace of projectWorkspaceRows) {
|
||||||
const projectCwd = readNonEmptyString(workspace.cwd);
|
const projectCwd = readNonEmptyString(workspace.cwd);
|
||||||
if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) {
|
if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) {
|
||||||
|
if (preferredWorkspace?.id === workspace.id) {
|
||||||
|
preferredWorkspaceWarning = `Selected project workspace "${workspace.name}" has no local cwd configured.`;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
hasConfiguredProjectCwd = true;
|
hasConfiguredProjectCwd = true;
|
||||||
@@ -590,15 +626,22 @@ export function heartbeatService(db: Db) {
|
|||||||
repoUrl: workspace.repoUrl,
|
repoUrl: workspace.repoUrl,
|
||||||
repoRef: workspace.repoRef,
|
repoRef: workspace.repoRef,
|
||||||
workspaceHints,
|
workspaceHints,
|
||||||
warnings: [],
|
warnings: preferredWorkspaceWarning ? [preferredWorkspaceWarning] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (preferredWorkspace?.id === workspace.id) {
|
||||||
|
preferredWorkspaceWarning =
|
||||||
|
`Selected project workspace path "${projectCwd}" is not available yet.`;
|
||||||
|
}
|
||||||
missingProjectCwds.push(projectCwd);
|
missingProjectCwds.push(projectCwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id);
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id);
|
||||||
await fs.mkdir(fallbackCwd, { recursive: true });
|
await fs.mkdir(fallbackCwd, { recursive: true });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
if (preferredWorkspaceWarning) {
|
||||||
|
warnings.push(preferredWorkspaceWarning);
|
||||||
|
}
|
||||||
if (missingProjectCwds.length > 0) {
|
if (missingProjectCwds.length > 0) {
|
||||||
const firstMissing = missingProjectCwds[0];
|
const firstMissing = missingProjectCwds[0];
|
||||||
const extraMissingCount = Math.max(0, missingProjectCwds.length - 1);
|
const extraMissingCount = Math.max(0, missingProjectCwds.length - 1);
|
||||||
@@ -1464,6 +1507,7 @@ export function heartbeatService(db: Db) {
|
|||||||
},
|
},
|
||||||
issue: issueRef,
|
issue: issueRef,
|
||||||
workspace: executionWorkspace,
|
workspace: executionWorkspace,
|
||||||
|
executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
adapterEnv,
|
adapterEnv,
|
||||||
onLog,
|
onLog,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface RuntimeServiceRef {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
projectWorkspaceId: string | null;
|
projectWorkspaceId: string | null;
|
||||||
|
executionWorkspaceId: string | null;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
serviceName: string;
|
serviceName: string;
|
||||||
status: "starting" | "running" | "stopped" | "failed";
|
status: "starting" | "running" | "stopped" | "failed";
|
||||||
@@ -92,6 +93,17 @@ function stableStringify(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
if (key.startsWith("PAPERCLIP_")) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete env.DATABASE_URL;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
function stableRuntimeServiceId(input: {
|
function stableRuntimeServiceId(input: {
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -126,6 +138,7 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<R
|
|||||||
companyId: record.companyId,
|
companyId: record.companyId,
|
||||||
projectId: record.projectId,
|
projectId: record.projectId,
|
||||||
projectWorkspaceId: record.projectWorkspaceId,
|
projectWorkspaceId: record.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: record.executionWorkspaceId,
|
||||||
issueId: record.issueId,
|
issueId: record.issueId,
|
||||||
serviceName: record.serviceName,
|
serviceName: record.serviceName,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
@@ -330,6 +343,55 @@ async function provisionExecutionWorktree(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildExecutionWorkspaceCleanupEnv(input: {
|
||||||
|
workspace: {
|
||||||
|
cwd: string | null;
|
||||||
|
providerRef: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
repoUrl: string | null;
|
||||||
|
baseRef: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
sourceIssueId: string | null;
|
||||||
|
};
|
||||||
|
projectWorkspaceCwd?: string | null;
|
||||||
|
}) {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||||
|
env.PAPERCLIP_WORKSPACE_CWD = input.workspace.cwd ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_PATH = input.workspace.cwd ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH =
|
||||||
|
input.workspace.providerRef ?? input.workspace.cwd ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_BRANCH = input.workspace.branchName ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_BASE_CWD = input.projectWorkspaceCwd ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_REPO_ROOT = input.projectWorkspaceCwd ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_REPO_URL = input.workspace.repoUrl ?? "";
|
||||||
|
env.PAPERCLIP_WORKSPACE_REPO_REF = input.workspace.baseRef ?? "";
|
||||||
|
env.PAPERCLIP_PROJECT_ID = input.workspace.projectId ?? "";
|
||||||
|
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.workspace.projectWorkspaceId ?? "";
|
||||||
|
env.PAPERCLIP_ISSUE_ID = input.workspace.sourceIssueId ?? "";
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGitRepoRootForWorkspaceCleanup(
|
||||||
|
worktreePath: string,
|
||||||
|
projectWorkspaceCwd: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (projectWorkspaceCwd) {
|
||||||
|
const resolvedProjectWorkspaceCwd = path.resolve(projectWorkspaceCwd);
|
||||||
|
const gitDir = await runGit(["rev-parse", "--git-common-dir"], resolvedProjectWorkspaceCwd)
|
||||||
|
.catch(() => null);
|
||||||
|
if (gitDir) {
|
||||||
|
const resolvedGitDir = path.resolve(resolvedProjectWorkspaceCwd, gitDir);
|
||||||
|
return path.dirname(resolvedGitDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitDir = await runGit(["rev-parse", "--git-common-dir"], worktreePath).catch(() => null);
|
||||||
|
if (!gitDir) return null;
|
||||||
|
const resolvedGitDir = path.resolve(worktreePath, gitDir);
|
||||||
|
return path.dirname(resolvedGitDir);
|
||||||
|
}
|
||||||
|
|
||||||
export async function realizeExecutionWorkspace(input: {
|
export async function realizeExecutionWorkspace(input: {
|
||||||
base: ExecutionWorkspaceInput;
|
base: ExecutionWorkspaceInput;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
@@ -418,6 +480,98 @@ export async function realizeExecutionWorkspace(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||||
|
workspace: {
|
||||||
|
id: string;
|
||||||
|
cwd: string | null;
|
||||||
|
providerType: string;
|
||||||
|
providerRef: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
repoUrl: string | null;
|
||||||
|
baseRef: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
sourceIssueId: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
projectWorkspace?: {
|
||||||
|
cwd: string | null;
|
||||||
|
cleanupCommand: string | null;
|
||||||
|
} | null;
|
||||||
|
teardownCommand?: string | null;
|
||||||
|
}) {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const workspacePath = input.workspace.providerRef ?? input.workspace.cwd;
|
||||||
|
const cleanupEnv = buildExecutionWorkspaceCleanupEnv({
|
||||||
|
workspace: input.workspace,
|
||||||
|
projectWorkspaceCwd: input.projectWorkspace?.cwd ?? null,
|
||||||
|
});
|
||||||
|
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
||||||
|
const cleanupCommands = [
|
||||||
|
input.projectWorkspace?.cleanupCommand ?? null,
|
||||||
|
input.teardownCommand ?? null,
|
||||||
|
]
|
||||||
|
.map((value) => asString(value, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const command of cleanupCommands) {
|
||||||
|
try {
|
||||||
|
await runWorkspaceCommand({
|
||||||
|
command,
|
||||||
|
cwd: workspacePath ?? input.projectWorkspace?.cwd ?? process.cwd(),
|
||||||
|
env: cleanupEnv,
|
||||||
|
label: `Execution workspace cleanup command "${command}"`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.workspace.providerType === "git_worktree" && workspacePath) {
|
||||||
|
const worktreeExists = await directoryExists(workspacePath);
|
||||||
|
if (worktreeExists) {
|
||||||
|
const repoRoot = await resolveGitRepoRootForWorkspaceCleanup(
|
||||||
|
workspacePath,
|
||||||
|
input.projectWorkspace?.cwd ?? null,
|
||||||
|
);
|
||||||
|
if (!repoRoot) {
|
||||||
|
warnings.push(`Could not resolve git repo root for "${workspacePath}".`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await runGit(["worktree", "remove", "--force", workspacePath], repoRoot);
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
if (createdByRuntime && input.workspace.branchName) {
|
||||||
|
try {
|
||||||
|
await runGit(["branch", "-D", input.workspace.branchName], repoRoot);
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (input.workspace.providerType === "local_fs" && createdByRuntime && workspacePath) {
|
||||||
|
const projectWorkspaceCwd = input.projectWorkspace?.cwd ? path.resolve(input.projectWorkspace.cwd) : null;
|
||||||
|
const resolvedWorkspacePath = path.resolve(workspacePath);
|
||||||
|
if (projectWorkspaceCwd && resolvedWorkspacePath === projectWorkspaceCwd) {
|
||||||
|
warnings.push(`Refusing to remove shared project workspace "${workspacePath}".`);
|
||||||
|
} else {
|
||||||
|
await fs.rm(resolvedWorkspacePath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned =
|
||||||
|
!workspacePath ||
|
||||||
|
!(await directoryExists(workspacePath));
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanedPath: workspacePath,
|
||||||
|
cleaned,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function allocatePort(): Promise<number> {
|
async function allocatePort(): Promise<number> {
|
||||||
return await new Promise<number>((resolve, reject) => {
|
return await new Promise<number>((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -521,6 +675,7 @@ function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeo
|
|||||||
companyId: record.companyId,
|
companyId: record.companyId,
|
||||||
projectId: record.projectId,
|
projectId: record.projectId,
|
||||||
projectWorkspaceId: record.projectWorkspaceId,
|
projectWorkspaceId: record.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: record.executionWorkspaceId,
|
||||||
issueId: record.issueId,
|
issueId: record.issueId,
|
||||||
scopeType: record.scopeType,
|
scopeType: record.scopeType,
|
||||||
scopeId: record.scopeId,
|
scopeId: record.scopeId,
|
||||||
@@ -556,6 +711,7 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
|
|||||||
set: {
|
set: {
|
||||||
projectId: values.projectId,
|
projectId: values.projectId,
|
||||||
projectWorkspaceId: values.projectWorkspaceId,
|
projectWorkspaceId: values.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: values.executionWorkspaceId,
|
||||||
issueId: values.issueId,
|
issueId: values.issueId,
|
||||||
scopeType: values.scopeType,
|
scopeType: values.scopeType,
|
||||||
scopeId: values.scopeId,
|
scopeId: values.scopeId,
|
||||||
@@ -593,6 +749,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
|
|||||||
agent: ExecutionWorkspaceAgentRef;
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
issue: ExecutionWorkspaceIssueRef | null;
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
workspace: RealizedExecutionWorkspace;
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
reports: AdapterRuntimeServiceReport[];
|
reports: AdapterRuntimeServiceReport[];
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}): RuntimeServiceRef[] {
|
}): RuntimeServiceRef[] {
|
||||||
@@ -629,6 +786,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
|
|||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
projectId: report.projectId ?? input.workspace.projectId,
|
projectId: report.projectId ?? input.workspace.projectId,
|
||||||
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
|
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
issueId: report.issueId ?? input.issue?.id ?? null,
|
issueId: report.issueId ?? input.issue?.id ?? null,
|
||||||
serviceName,
|
serviceName,
|
||||||
status,
|
status,
|
||||||
@@ -660,6 +818,7 @@ async function startLocalRuntimeService(input: {
|
|||||||
agent: ExecutionWorkspaceAgentRef;
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
issue: ExecutionWorkspaceIssueRef | null;
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
workspace: RealizedExecutionWorkspace;
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
adapterEnv: Record<string, string>;
|
adapterEnv: Record<string, string>;
|
||||||
service: Record<string, unknown>;
|
service: Record<string, unknown>;
|
||||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
@@ -683,7 +842,10 @@ async function startLocalRuntimeService(input: {
|
|||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
||||||
const env: Record<string, string> = { ...process.env, ...input.adapterEnv } as Record<string, string>;
|
const env: Record<string, string> = {
|
||||||
|
...sanitizeRuntimeServiceBaseEnv(process.env),
|
||||||
|
...input.adapterEnv,
|
||||||
|
} as Record<string, string>;
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
env[key] = renderTemplate(value, templateData);
|
env[key] = renderTemplate(value, templateData);
|
||||||
@@ -735,6 +897,7 @@ async function startLocalRuntimeService(input: {
|
|||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
projectId: input.workspace.projectId,
|
projectId: input.workspace.projectId,
|
||||||
projectWorkspaceId: input.workspace.workspaceId,
|
projectWorkspaceId: input.workspace.workspaceId,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
issueId: input.issue?.id ?? null,
|
issueId: input.issue?.id ?? null,
|
||||||
serviceName,
|
serviceName,
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -791,6 +954,28 @@ async function stopRuntimeService(serviceId: string) {
|
|||||||
await persistRuntimeServiceRecord(record.db, record);
|
await persistRuntimeServiceRecord(record.db, record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: {
|
||||||
|
db: Db;
|
||||||
|
executionWorkspaceId: string;
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
await input.db
|
||||||
|
.update(workspaceRuntimeServices)
|
||||||
|
.set({
|
||||||
|
status: "stopped",
|
||||||
|
healthStatus: "unknown",
|
||||||
|
stoppedAt: now,
|
||||||
|
lastUsedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId),
|
||||||
|
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
|
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
|
||||||
record.db = db;
|
record.db = db;
|
||||||
runtimeServicesById.set(record.id, record);
|
runtimeServicesById.set(record.id, record);
|
||||||
@@ -820,6 +1005,7 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||||||
agent: ExecutionWorkspaceAgentRef;
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
issue: ExecutionWorkspaceIssueRef | null;
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
workspace: RealizedExecutionWorkspace;
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
adapterEnv: Record<string, string>;
|
adapterEnv: Record<string, string>;
|
||||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
@@ -871,6 +1057,7 @@ export async function ensureRuntimeServicesForRun(input: {
|
|||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
issue: input.issue,
|
issue: input.issue,
|
||||||
workspace: input.workspace,
|
workspace: input.workspace,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId,
|
||||||
adapterEnv: input.adapterEnv,
|
adapterEnv: input.adapterEnv,
|
||||||
service,
|
service,
|
||||||
onLog: input.onLog,
|
onLog: input.onLog,
|
||||||
@@ -911,6 +1098,32 @@ export async function releaseRuntimeServicesForRun(runId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function stopRuntimeServicesForExecutionWorkspace(input: {
|
||||||
|
db?: Db;
|
||||||
|
executionWorkspaceId: string;
|
||||||
|
workspaceCwd?: string | null;
|
||||||
|
}) {
|
||||||
|
const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null;
|
||||||
|
const matchingServiceIds = Array.from(runtimeServicesById.values())
|
||||||
|
.filter((record) => {
|
||||||
|
if (record.executionWorkspaceId === input.executionWorkspaceId) return true;
|
||||||
|
if (!normalizedWorkspaceCwd || !record.cwd) return false;
|
||||||
|
return path.resolve(record.cwd).startsWith(normalizedWorkspaceCwd);
|
||||||
|
})
|
||||||
|
.map((record) => record.id);
|
||||||
|
|
||||||
|
for (const serviceId of matchingServiceIds) {
|
||||||
|
await stopRuntimeService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.db) {
|
||||||
|
await markPersistedRuntimeServicesStoppedForExecutionWorkspace({
|
||||||
|
db: input.db,
|
||||||
|
executionWorkspaceId: input.executionWorkspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||||
db: Db,
|
db: Db,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -978,6 +1191,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
|
|||||||
agent: ExecutionWorkspaceAgentRef;
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
issue: ExecutionWorkspaceIssueRef | null;
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
workspace: RealizedExecutionWorkspace;
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
executionWorkspaceId?: string | null;
|
||||||
reports: AdapterRuntimeServiceReport[];
|
reports: AdapterRuntimeServiceReport[];
|
||||||
}) {
|
}) {
|
||||||
const refs = normalizeAdapterManagedRuntimeServices(input);
|
const refs = normalizeAdapterManagedRuntimeServices(input);
|
||||||
@@ -1000,6 +1214,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
|
|||||||
companyId: ref.companyId,
|
companyId: ref.companyId,
|
||||||
projectId: ref.projectId,
|
projectId: ref.projectId,
|
||||||
projectWorkspaceId: ref.projectWorkspaceId,
|
projectWorkspaceId: ref.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: ref.executionWorkspaceId,
|
||||||
issueId: ref.issueId,
|
issueId: ref.issueId,
|
||||||
scopeType: ref.scopeType,
|
scopeType: ref.scopeType,
|
||||||
scopeId: ref.scopeId,
|
scopeId: ref.scopeId,
|
||||||
@@ -1028,6 +1243,7 @@ export async function persistAdapterManagedRuntimeServices(input: {
|
|||||||
set: {
|
set: {
|
||||||
projectId: ref.projectId,
|
projectId: ref.projectId,
|
||||||
projectWorkspaceId: ref.projectWorkspaceId,
|
projectWorkspaceId: ref.projectWorkspaceId,
|
||||||
|
executionWorkspaceId: ref.executionWorkspaceId,
|
||||||
issueId: ref.issueId,
|
issueId: ref.issueId,
|
||||||
scopeType: ref.scopeType,
|
scopeType: ref.scopeType,
|
||||||
scopeId: ref.scopeId,
|
scopeId: ref.scopeId,
|
||||||
|
|||||||
Reference in New Issue
Block a user