Add idempotent local dev service management

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 15:42:44 -05:00
parent cadfcd1bc6
commit 6793dde597
8 changed files with 1448 additions and 35 deletions

View file

@ -1,25 +1,48 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
workspaceRuntimeServices,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
cleanupExecutionWorkspaceArtifacts,
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
reconcilePersistedRuntimeServicesOnStartup,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
resetRuntimeServicesForTests,
stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const execFileAsync = promisify(execFile);
const leasedRunIds = new Set<string>();
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
@ -128,6 +151,7 @@ afterEach(async () => {
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_WORKTREES_DIR;
delete process.env.DATABASE_URL;
await resetRuntimeServicesForTests();
});
describe("realizeExecutionWorkspace", () => {
@ -1028,6 +1052,135 @@ describe("ensureRuntimeServicesForRun", () => {
});
});
describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
it("adopts a live auto-port shared service after runtime state is reset", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-"));
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = `runtime-reconcile-${randomUUID()}`;
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date(),
updatedAt: new Date(),
});
const workspace = {
...buildWorkspace(workspaceRoot),
projectId: null,
workspaceId: null,
};
leasedRunIds.add(runId);
const services = await ensureRuntimeServicesForRun({
db,
runId,
agent: {
id: agentId,
name: "Codex Coder",
companyId,
},
issue: null,
workspace,
config: {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
lifecycle: "shared",
reuseScope: "agent",
stopPolicy: {
type: "manual",
},
},
],
},
},
adapterEnv: {},
});
expect(services).toHaveLength(1);
const service = services[0];
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
await resetRuntimeServicesForTests();
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 });
const persisted = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.id, service!.id))
.then((rows) => rows[0] ?? null);
expect(persisted?.status).toBe("running");
expect(persisted?.providerRef).toMatch(/^\d+$/);
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
await expect(fetch(service!.url!)).rejects.toThrow();
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
const workspace = buildWorkspace("/tmp/project");

View file

@ -0,0 +1,302 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const execFileAsync = promisify(execFile);
export interface LocalServiceRegistryRecord {
version: 1;
serviceKey: string;
profileKind: string;
serviceName: string;
command: string;
cwd: string;
envFingerprint: string;
port: number | null;
url: string | null;
pid: number;
processGroupId: number | null;
provider: "local_process";
runtimeServiceId: string | null;
reuseKey: string | null;
startedAt: string;
lastSeenAt: string;
metadata: Record<string, unknown> | null;
}
export interface LocalServiceIdentityInput {
profileKind: string;
serviceName: string;
cwd: string;
command: string;
envFingerprint: string;
port: number | null;
scope: Record<string, unknown> | null;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sanitizeServiceKeySegment(value: string, fallback: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function getRuntimeServicesDir() {
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
}
function getRuntimeServiceRegistryPath(serviceKey: string) {
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
}
function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null {
if (!raw || typeof raw !== "object") return null;
const rec = raw as Record<string, unknown>;
if (
rec.version !== 1 ||
typeof rec.serviceKey !== "string" ||
typeof rec.profileKind !== "string" ||
typeof rec.serviceName !== "string" ||
typeof rec.command !== "string" ||
typeof rec.cwd !== "string" ||
typeof rec.envFingerprint !== "string" ||
typeof rec.pid !== "number"
) {
return null;
}
return {
version: 1,
serviceKey: rec.serviceKey,
profileKind: rec.profileKind,
serviceName: rec.serviceName,
command: rec.command,
cwd: rec.cwd,
envFingerprint: rec.envFingerprint,
port: typeof rec.port === "number" ? rec.port : null,
url: typeof rec.url === "string" ? rec.url : null,
pid: rec.pid,
processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null,
provider: "local_process",
runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null,
reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null,
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(),
lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(),
metadata:
rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata)
? (rec.metadata as Record<string, unknown>)
: null,
};
}
async function safeReadRegistryRecord(filePath: string) {
try {
const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return normalizeRegistryRecord(raw);
} catch {
return null;
}
}
export function createLocalServiceKey(input: LocalServiceIdentityInput) {
const digest = createHash("sha256")
.update(
stableStringify({
profileKind: input.profileKind,
serviceName: input.serviceName,
cwd: path.resolve(input.cwd),
command: input.command,
envFingerprint: input.envFingerprint,
port: input.port,
scope: input.scope ?? null,
}),
)
.digest("hex")
.slice(0, 24);
return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`;
}
export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) {
await fs.mkdir(getRuntimeServicesDir(), { recursive: true });
await fs.writeFile(
getRuntimeServiceRegistryPath(record.serviceKey),
`${JSON.stringify(record, null, 2)}\n`,
"utf8",
);
}
export async function removeLocalServiceRegistryRecord(serviceKey: string) {
await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true });
}
export async function readLocalServiceRegistryRecord(serviceKey: string) {
return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey));
}
export async function listLocalServiceRegistryRecords(filter?: {
profileKind?: string;
metadata?: Record<string, unknown>;
}) {
try {
const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true });
const records = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))),
);
return records
.filter((record): record is LocalServiceRegistryRecord => record !== null)
.filter((record) => {
if (filter?.profileKind && record.profileKind !== filter.profileKind) return false;
if (!filter?.metadata) return true;
return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value);
})
.sort((left, right) => left.serviceKey.localeCompare(right.serviceKey));
} catch {
return [];
}
}
export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
runtimeServiceId: string;
profileKind?: string;
}) {
const records = await listLocalServiceRegistryRecords(
input.profileKind ? { profileKind: input.profileKind } : undefined,
);
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
}
export function isPidAlive(pid: number) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
if (process.platform === "win32") return true;
try {
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim();
if (!commandLine) return false;
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
} catch {
return true;
}
}
export async function findAdoptableLocalService(input: {
serviceKey: string;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
}) {
const record = await readLocalServiceRegistryRecord(input.serviceKey);
if (!record) return null;
if (!isPidAlive(record.pid)) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (!(await isLikelyMatchingCommand(record))) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (input.command && record.command !== input.command) return null;
if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null;
if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null;
if (input.port !== undefined && input.port !== null && record.port !== input.port) return null;
return record;
}
export async function touchLocalServiceRegistryRecord(
serviceKey: string,
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
) {
const existing = await readLocalServiceRegistryRecord(serviceKey);
if (!existing) return null;
const next: LocalServiceRegistryRecord = {
...existing,
...patch,
version: 1,
serviceKey,
lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(next);
return next;
}
export async function terminateLocalService(
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId">,
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number },
) {
const signal = opts?.signal ?? "SIGTERM";
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, signal);
} else {
process.kill(record.pid, signal);
}
} catch {
return;
}
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
while (Date.now() < deadline) {
if (!isPidAlive(record.pid)) {
return;
}
await delay(100);
}
if (!isPidAlive(record.pid)) return;
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, "SIGKILL");
} else {
process.kill(record.pid, "SIGKILL");
}
} catch {
// Ignore cleanup races.
}
}
export async function readLocalServicePortOwner(port: number) {
if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null;
try {
const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]);
const firstPid = stdout
.split("\n")
.map((line) => Number.parseInt(line.trim(), 10))
.find((value) => Number.isInteger(value) && value > 0);
return firstPid ?? null;
} catch {
return null;
}
}

View file

@ -10,6 +10,16 @@ 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 {
createLocalServiceKey,
findLocalServiceRegistryRecordByRuntimeServiceId,
findAdoptableLocalService,
readLocalServicePortOwner,
removeLocalServiceRegistryRecord,
terminateLocalService,
touchLocalServiceRegistryRecord,
writeLocalServiceRegistryRecord,
} from "./local-service-supervisor.js";
import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
export interface ExecutionWorkspaceInput {
@ -77,12 +87,24 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
leaseRunIds: Set<string>;
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
envFingerprint: string;
serviceKey: string;
profileKind: string;
processGroupId: number | null;
}
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
clearIdleTimer(record);
}
runtimeServicesById.clear();
runtimeServicesByReuseKey.clear();
runtimeServiceLeasesByRun.clear();
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
@ -1101,8 +1123,17 @@ async function startLocalRuntimeService(input: {
if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`);
const serviceCwdTemplate = asString(input.service.cwd, ".");
const portConfig = parseObject(input.service.port);
const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null;
const envConfig = parseObject(input.service.env);
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
const identityPort = explicitPort > 0 ? explicitPort : null;
const port =
asString(portConfig.type, "") === "auto"
? await allocatePort()
: explicitPort > 0
? explicitPort
: null;
const templateData = buildTemplateData({
workspace: input.workspace,
agent: input.agent,
@ -1124,6 +1155,80 @@ async function startLocalRuntimeService(input: {
const portEnvKey = asString(portConfig.envKey, "PORT");
env[portEnvKey] = String(port);
}
const expose = parseObject(input.service.expose);
const readiness = parseObject(input.service.readiness);
const urlTemplate =
asString(expose.urlTemplate, "") ||
asString(readiness.urlTemplate, "");
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
const stopPolicy = parseObject(input.service.stopPolicy);
const serviceKey = createLocalServiceKey({
profileKind: "workspace-runtime",
serviceName,
cwd: serviceCwd,
command,
envFingerprint: serviceIdentityFingerprint,
port: identityPort,
scope: {
scopeType: input.scopeType,
scopeId: input.scopeId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
reuseKey: input.reuseKey,
},
});
const adoptedRecord = await findAdoptableLocalService({
serviceKey,
command,
cwd: serviceCwd,
envFingerprint: serviceIdentityFingerprint,
port: identityPort,
});
if (adoptedRecord) {
return {
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issue?.id ?? null,
serviceName,
status: "running",
lifecycle,
scopeType: input.scopeType,
scopeId: input.scopeId,
reuseKey: input.reuseKey,
command,
cwd: serviceCwd,
port: adoptedRecord.port ?? port,
url: adoptedRecord.url ?? url,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: input.agent.id,
startedByRunId: input.runId,
lastUsedAt: new Date().toISOString(),
startedAt: adoptedRecord.startedAt,
stoppedAt: null,
stopPolicy,
healthStatus: "healthy",
reused: true,
db: input.db,
child: null,
leaseRunIds: new Set([input.runId]),
idleTimer: null,
envFingerprint,
serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
}
if (identityPort) {
const ownerPid = await readLocalServicePortOwner(identityPort);
if (ownerPid) {
throw new Error(
`Runtime service "${serviceName}" could not start because port ${identityPort} is already in use by pid ${ownerPid}`,
);
}
}
const shell = process.env.SHELL?.trim() || "/bin/sh";
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,
@ -1144,13 +1249,6 @@ async function startLocalRuntimeService(input: {
if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`);
});
const expose = parseObject(input.service.expose);
const readiness = parseObject(input.service.readiness);
const urlTemplate =
asString(expose.urlTemplate, "") ||
asString(readiness.urlTemplate, "");
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
try {
await waitForReadiness({ service: input.service, url });
} catch (err) {
@ -1160,8 +1258,7 @@ async function startLocalRuntimeService(input: {
);
}
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
return {
const record: RuntimeServiceRecord = {
id: randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
@ -1185,7 +1282,7 @@ async function startLocalRuntimeService(input: {
lastUsedAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
stoppedAt: null,
stopPolicy: parseObject(input.service.stopPolicy),
stopPolicy,
healthStatus: "healthy",
reused: false,
db: input.db,
@ -1193,7 +1290,41 @@ async function startLocalRuntimeService(input: {
leaseRunIds: new Set([input.runId]),
idleTimer: null,
envFingerprint,
serviceKey,
profileKind: "workspace-runtime",
processGroupId: child.pid ?? null,
};
if (child.pid) {
await writeLocalServiceRegistryRecord({
version: 1,
serviceKey,
profileKind: "workspace-runtime",
serviceName,
command,
cwd: serviceCwd,
envFingerprint: serviceIdentityFingerprint,
port,
url,
pid: child.pid,
processGroupId: child.pid,
provider: "local_process",
runtimeServiceId: record.id,
reuseKey: input.reuseKey,
startedAt: record.startedAt,
lastSeenAt: record.lastUsedAt,
metadata: {
projectId: record.projectId,
projectWorkspaceId: record.projectWorkspaceId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
scopeType: record.scopeType,
scopeId: record.scopeId,
},
});
}
return record;
}
function scheduleIdleStop(record: RuntimeServiceRecord) {
@ -1215,11 +1346,20 @@ async function stopRuntimeService(serviceId: string) {
record.stoppedAt = new Date().toISOString();
if (record.child && record.child.pid) {
terminateChildProcess(record.child);
} else if (record.providerRef) {
const pid = Number.parseInt(record.providerRef, 10);
if (Number.isInteger(pid) && pid > 0) {
await terminateLocalService({
pid,
processGroupId: record.processGroupId,
});
}
}
runtimeServicesById.delete(serviceId);
if (record.reuseKey) {
runtimeServicesByReuseKey.delete(record.reuseKey);
}
await removeLocalServiceRegistryRecord(record.serviceKey);
await persistRuntimeServiceRecord(record.db, record);
}
@ -1264,6 +1404,7 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
runtimeServicesByReuseKey.delete(current.reuseKey);
}
void removeLocalServiceRegistryRecord(current.serviceKey);
void persistRuntimeServiceRecord(db, current);
});
}
@ -1314,6 +1455,10 @@ export async function ensureRuntimeServicesForRun(input: {
existing.lastUsedAt = new Date().toISOString();
existing.stoppedAt = null;
clearIdleTimer(existing);
void touchLocalServiceRegistryRecord(existing.serviceKey, {
runtimeServiceId: existing.id,
lastSeenAt: existing.lastUsedAt,
});
await persistRuntimeServiceRecord(input.db, existing);
acquiredServiceIds.push(existing.id);
refs.push(toRuntimeServiceRef(existing, { reused: true }));
@ -1426,8 +1571,8 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
}
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
const staleRows = await db
.select({ id: workspaceRuntimeServices.id })
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
@ -1436,26 +1581,84 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
),
);
if (staleRows.length === 0) return { reconciled: 0 };
if (rows.length === 0) return { reconciled: 0, adopted: 0, stopped: 0 };
const now = new Date();
await db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(
and(
eq(workspaceRuntimeServices.provider, "local_process"),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
);
let adopted = 0;
let stopped = 0;
for (const row of rows) {
const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
runtimeServiceId: row.id,
profileKind: "workspace-runtime",
});
if (adoptedRecord) {
const record: RuntimeServiceRecord = {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
serviceName: row.serviceName,
status: "running",
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
scopeId: row.scopeId ?? null,
reuseKey: row.reuseKey ?? null,
command: row.command ?? null,
cwd: row.cwd ?? null,
port: adoptedRecord.port ?? row.port ?? null,
url: adoptedRecord.url ?? row.url ?? null,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: row.ownerAgentId ?? null,
startedByRunId: row.startedByRunId ?? null,
lastUsedAt: new Date().toISOString(),
startedAt: row.startedAt.toISOString(),
stoppedAt: null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
healthStatus: "healthy",
reused: true,
db,
child: null,
leaseRunIds: new Set(),
idleTimer: null,
envFingerprint: row.reuseKey ?? "",
serviceKey: adoptedRecord.serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
registerRuntimeService(db, record);
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
runtimeServiceId: row.id,
lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
return { reconciled: staleRows.length };
const now = new Date();
await db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(eq(workspaceRuntimeServices.id, row.id));
const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
runtimeServiceId: row.id,
profileKind: "workspace-runtime",
});
if (registryRecord) {
await removeLocalServiceRegistryRecord(registryRecord.serviceKey);
}
stopped += 1;
}
return { reconciled: rows.length, adopted, stopped };
}
export async function persistAdapterManagedRuntimeServices(input: {