mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Fix workspace runtime state reconciliation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5a9a2a9112
commit
f515f2aa12
9 changed files with 477 additions and 64 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
issues,
|
issues,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceRuntimeServices,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
|
|
@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(workspaceRuntimeServices);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(projectWorkspaces);
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
|
|
@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||||
"git_branch_delete",
|
"git_branch_delete",
|
||||||
]));
|
]));
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
const executionWorkspaceId = randomUUID();
|
||||||
|
const olderServiceId = randomUUID();
|
||||||
|
const currentServiceId = randomUUID();
|
||||||
|
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
|
||||||
|
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||||
|
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
|
||||||
|
const runningAt = new Date("2026-04-04T17:10:00.000Z");
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Workspaces",
|
||||||
|
status: "in_progress",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary",
|
||||||
|
sourceType: "local_path",
|
||||||
|
isPrimary: true,
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
metadata: {
|
||||||
|
runtimeConfig: {
|
||||||
|
desiredState: "running",
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.insert(executionWorkspaces).values({
|
||||||
|
id: executionWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
mode: "shared_workspace",
|
||||||
|
strategyType: "project_primary",
|
||||||
|
name: "Shared workspace",
|
||||||
|
status: "active",
|
||||||
|
providerType: "local_fs",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
});
|
||||||
|
await db.insert(workspaceRuntimeServices).values([
|
||||||
|
{
|
||||||
|
id: olderServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "stopped",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "11111",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: stoppedAt,
|
||||||
|
startedAt,
|
||||||
|
stoppedAt,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "unknown",
|
||||||
|
createdAt: startedAt,
|
||||||
|
updatedAt: stoppedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: currentServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49222,
|
||||||
|
url: "http://127.0.0.1:49222",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "22222",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: runningAt,
|
||||||
|
startedAt: runningAt,
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "healthy",
|
||||||
|
createdAt: runningAt,
|
||||||
|
updatedAt: runningAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspace = await svc.getById(executionWorkspaceId);
|
||||||
|
const listed = await svc.list(companyId, { projectId });
|
||||||
|
|
||||||
|
expect(workspace?.runtimeServices).toHaveLength(1);
|
||||||
|
expect(workspace?.runtimeServices?.[0]).toMatchObject({
|
||||||
|
id: currentServiceId,
|
||||||
|
status: "running",
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
url: "http://127.0.0.1:49222",
|
||||||
|
});
|
||||||
|
expect(listed[0]?.runtimeServices).toHaveLength(1);
|
||||||
|
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
createDb,
|
createDb,
|
||||||
executionWorkspaces,
|
executionWorkspaces,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
workspaceRuntimeServices,
|
workspaceRuntimeServices,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
|
|
@ -30,6 +31,7 @@ import {
|
||||||
stopRuntimeServicesForExecutionWorkspace,
|
stopRuntimeServicesForExecutionWorkspace,
|
||||||
type RealizedExecutionWorkspace,
|
type RealizedExecutionWorkspace,
|
||||||
} from "../services/workspace-runtime.ts";
|
} from "../services/workspace-runtime.ts";
|
||||||
|
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||||
|
|
@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(workspaceRuntimeServices);
|
await db.delete(workspaceRuntimeServices);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
|
|
@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks persisted local services stopped when the registry pid is stale", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const runtimeServiceId = randomUUID();
|
||||||
|
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||||
|
const updatedAt = new Date("2026-04-04T17:10:00.000Z");
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Runtime reconcile test",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(workspaceRuntimeServices).values({
|
||||||
|
id: runtimeServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "999999",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: updatedAt,
|
||||||
|
startedAt,
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "healthy",
|
||||||
|
createdAt: startedAt,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
await writeLocalServiceRegistryRecord({
|
||||||
|
version: 1,
|
||||||
|
serviceKey: "workspace-runtime-paperclip-dev-stale",
|
||||||
|
profileKind: "workspace-runtime",
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
envFingerprint: "fingerprint",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
pid: 999999,
|
||||||
|
processGroupId: 999999,
|
||||||
|
provider: "local_process",
|
||||||
|
runtimeServiceId,
|
||||||
|
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
lastSeenAt: updatedAt.toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 });
|
||||||
|
const persisted = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(persisted?.status).toBe("stopped");
|
||||||
|
expect(persisted?.stoppedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists controlled execution workspace stops as stopped", async () => {
|
it("persists controlled execution workspace stops as stopped", async () => {
|
||||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import type {
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
|
import {
|
||||||
|
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||||
|
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||||
|
} from "./workspace-runtime-read-model.js";
|
||||||
|
|
||||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||||
|
|
@ -317,6 +321,41 @@ function toExecutionWorkspace(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||||
|
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||||
|
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
rows: ExecutionWorkspaceRow[],
|
||||||
|
) {
|
||||||
|
const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces(
|
||||||
|
db,
|
||||||
|
companyId,
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
);
|
||||||
|
const projectWorkspaceIds = rows
|
||||||
|
.filter((row) => usesInheritedProjectRuntimeServices(row))
|
||||||
|
.map((row) => row.projectWorkspaceId)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
|
db,
|
||||||
|
companyId,
|
||||||
|
[...new Set(projectWorkspaceIds)],
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
usesInheritedProjectRuntimeServices(row)
|
||||||
|
? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? [])
|
||||||
|
: (executionRuntimeServices.get(row.id) ?? []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function executionWorkspaceService(db: Db) {
|
export function executionWorkspaceService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: async (companyId: string, filters?: {
|
list: async (companyId: string, filters?: {
|
||||||
|
|
@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.from(executionWorkspaces)
|
.from(executionWorkspaces)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||||
return rows.map((row) => toExecutionWorkspace(row));
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows);
|
||||||
|
return rows.map((row) =>
|
||||||
|
toExecutionWorkspace(
|
||||||
|
row,
|
||||||
|
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string) => {
|
getById: async (id: string) => {
|
||||||
|
|
@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.where(eq(executionWorkspaces.id, id))
|
.where(eq(executionWorkspaces.id, id))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const runtimeServiceRows = await db
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
|
||||||
.select()
|
return toExecutionWorkspace(
|
||||||
.from(workspaceRuntimeServices)
|
row,
|
||||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
);
|
||||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
||||||
|
|
@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
|
|
||||||
const runtimeServiceRows = await db
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
|
||||||
.select()
|
const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
|
||||||
.from(workspaceRuntimeServices)
|
|
||||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
|
|
||||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
|
||||||
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
|
|
||||||
|
|
||||||
const linkedIssues = await db
|
const linkedIssues = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
||||||
const records = await listLocalServiceRegistryRecords(
|
const records = await listLocalServiceRegistryRecords(
|
||||||
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
||||||
);
|
);
|
||||||
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
|
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
let candidate = record;
|
||||||
|
if (!isPidAlive(candidate.pid)) {
|
||||||
|
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
|
||||||
|
if (!ownerPid) {
|
||||||
|
await removeLocalServiceRegistryRecord(candidate.serviceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidate = {
|
||||||
|
...candidate,
|
||||||
|
pid: ownerPid,
|
||||||
|
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeLocalServiceRegistryRecord(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isLikelyMatchingCommand(candidate))) {
|
||||||
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPidAlive(pid: number) {
|
export function isPidAlive(pid: number) {
|
||||||
|
|
@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||||
const commandLine = stdout.trim();
|
const commandLine = stdout.trim();
|
||||||
if (!commandLine) return false;
|
if (!commandLine) return false;
|
||||||
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
|
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
const normalizedCommandLine = normalize(commandLine);
|
||||||
|
const normalizedRecordedCommand = normalize(record.command);
|
||||||
|
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
type ProjectWorkspace,
|
type ProjectWorkspace,
|
||||||
type WorkspaceRuntimeService,
|
type WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||||
|
|
@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
db,
|
db,
|
||||||
rows[0]!.companyId,
|
rows[0]!.companyId,
|
||||||
workspaceRows.map((workspace) => workspace.id),
|
workspaceRows.map((workspace) => workspace.id),
|
||||||
|
|
@ -541,7 +541,7 @@ export function projectService(db: Db) {
|
||||||
.where(eq(projectWorkspaces.projectId, projectId))
|
.where(eq(projectWorkspaces.projectId, projectId))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
db,
|
db,
|
||||||
rows[0]!.companyId,
|
rows[0]!.companyId,
|
||||||
rows.map((workspace) => workspace.id),
|
rows.map((workspace) => workspace.id),
|
||||||
|
|
|
||||||
96
server/src/services/workspace-runtime-read-model.ts
Normal file
96
server/src/services/workspace-runtime-read-model.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { workspaceRuntimeServices } from "@paperclipai/db";
|
||||||
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||||
|
|
||||||
|
function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) {
|
||||||
|
if (row.reuseKey) return row.reuseKey;
|
||||||
|
return [
|
||||||
|
row.scopeType,
|
||||||
|
row.scopeId ?? "",
|
||||||
|
row.projectWorkspaceId ?? "",
|
||||||
|
row.executionWorkspaceId ?? "",
|
||||||
|
row.serviceName,
|
||||||
|
row.command ?? "",
|
||||||
|
row.cwd ?? "",
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) {
|
||||||
|
const current = new Map<string, WorkspaceRuntimeServiceRow>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const identity = runtimeServiceIdentityKey(row);
|
||||||
|
if (!current.has(identity)) current.set(identity, row);
|
||||||
|
}
|
||||||
|
return [...current.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
projectWorkspaceIds: string[],
|
||||||
|
) {
|
||||||
|
if (projectWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRuntimeServices.companyId, companyId),
|
||||||
|
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
|
||||||
|
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||||
|
|
||||||
|
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.projectWorkspaceId) continue;
|
||||||
|
const existing = grouped.get(row.projectWorkspaceId) ?? [];
|
||||||
|
existing.push(row);
|
||||||
|
grouped.set(row.projectWorkspaceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
|
||||||
|
workspaceId,
|
||||||
|
selectCurrentRuntimeServiceRows(workspaceRows),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCurrentRuntimeServicesForExecutionWorkspaces(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
executionWorkspaceIds: string[],
|
||||||
|
) {
|
||||||
|
if (executionWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRuntimeServices.companyId, companyId),
|
||||||
|
inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||||
|
|
||||||
|
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.executionWorkspaceId) continue;
|
||||||
|
const existing = grouped.get(row.executionWorkspaceId) ?? [];
|
||||||
|
existing.push(row);
|
||||||
|
grouped.set(row.executionWorkspaceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
|
||||||
|
workspaceId,
|
||||||
|
selectCurrentRuntimeServiceRows(workspaceRows),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1081,6 +1081,16 @@ async function waitForReadiness(input: {
|
||||||
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isRuntimeServiceUrlHealthy(url: string | null) {
|
||||||
|
if (!url) return true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|
@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
||||||
profileKind: "workspace-runtime",
|
profileKind: "workspace-runtime",
|
||||||
});
|
});
|
||||||
if (adoptedRecord) {
|
if (adoptedRecord) {
|
||||||
const record: RuntimeServiceRecord = {
|
const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
|
||||||
id: row.id,
|
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
|
||||||
companyId: row.companyId,
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
||||||
projectId: row.projectId ?? null,
|
} else {
|
||||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
const record: RuntimeServiceRecord = {
|
||||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
id: row.id,
|
||||||
issueId: row.issueId ?? null,
|
companyId: row.companyId,
|
||||||
serviceName: row.serviceName,
|
projectId: row.projectId ?? null,
|
||||||
status: "running",
|
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||||
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
issueId: row.issueId ?? null,
|
||||||
scopeId: row.scopeId ?? null,
|
serviceName: row.serviceName,
|
||||||
reuseKey: row.reuseKey ?? null,
|
status: "running",
|
||||||
command: row.command ?? null,
|
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
||||||
cwd: row.cwd ?? null,
|
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||||
port: adoptedRecord.port ?? row.port ?? null,
|
scopeId: row.scopeId ?? null,
|
||||||
url: adoptedRecord.url ?? row.url ?? null,
|
reuseKey: row.reuseKey ?? null,
|
||||||
provider: "local_process",
|
command: row.command ?? null,
|
||||||
providerRef: String(adoptedRecord.pid),
|
cwd: row.cwd ?? null,
|
||||||
ownerAgentId: row.ownerAgentId ?? null,
|
port: adoptedRecord.port ?? row.port ?? null,
|
||||||
startedByRunId: row.startedByRunId ?? null,
|
url: adoptedRecord.url ?? row.url ?? null,
|
||||||
lastUsedAt: new Date().toISOString(),
|
provider: "local_process",
|
||||||
startedAt: row.startedAt.toISOString(),
|
providerRef: String(adoptedRecord.pid),
|
||||||
stoppedAt: null,
|
ownerAgentId: row.ownerAgentId ?? null,
|
||||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
startedByRunId: row.startedByRunId ?? null,
|
||||||
healthStatus: "healthy",
|
lastUsedAt: new Date().toISOString(),
|
||||||
reused: true,
|
startedAt: row.startedAt.toISOString(),
|
||||||
db,
|
stoppedAt: null,
|
||||||
child: null,
|
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||||
leaseRunIds: new Set(),
|
healthStatus: "healthy",
|
||||||
idleTimer: null,
|
reused: true,
|
||||||
envFingerprint: row.reuseKey ?? "",
|
db,
|
||||||
serviceKey: adoptedRecord.serviceKey,
|
child: null,
|
||||||
profileKind: "workspace-runtime",
|
leaseRunIds: new Set(),
|
||||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
idleTimer: null,
|
||||||
};
|
envFingerprint: row.reuseKey ?? "",
|
||||||
registerRuntimeService(db, record);
|
serviceKey: adoptedRecord.serviceKey,
|
||||||
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
profileKind: "workspace-runtime",
|
||||||
runtimeServiceId: row.id,
|
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||||
lastSeenAt: record.lastUsedAt,
|
};
|
||||||
});
|
registerRuntimeService(db, record);
|
||||||
await persistRuntimeServiceRecord(db, record);
|
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
||||||
adopted += 1;
|
runtimeServiceId: row.id,
|
||||||
continue;
|
lastSeenAt: record.lastUsedAt,
|
||||||
|
});
|
||||||
|
await persistRuntimeServiceRecord(db, record);
|
||||||
|
adopted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
|
||||||
return value ?? "";
|
return value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) {
|
||||||
|
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
|
||||||
|
}
|
||||||
|
|
||||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||||
if (!value || Object.keys(value).length === 0) return "";
|
if (!value || Object.keys(value).length === 0) return "";
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
|
|
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
|
||||||
return value ?? "";
|
return value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) {
|
||||||
|
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
|
||||||
|
}
|
||||||
|
|
||||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||||
if (!value || Object.keys(value).length === 0) return "";
|
if (!value || Object.keys(value).length === 0) return "";
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
|
|
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue