mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Add idempotent local dev service management
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cadfcd1bc6
commit
6793dde597
8 changed files with 1448 additions and 35 deletions
|
|
@ -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");
|
||||
|
|
|
|||
302
server/src/services/local-service-supervisor.ts
Normal file
302
server/src/services/local-service-supervisor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue