diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7864b90e..e85de611 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -39,6 +39,17 @@ This starts: `pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. +`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server. + +`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate. + +Inspect or stop the current repo's managed dev runner: + +```sh +pnpm dev:list +pnpm dev:stop +``` + `pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server. Tailscale/private-auth dev mode: diff --git a/docs/docs.json b/docs/docs.json index 90789e06..f87809af 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,6 +46,7 @@ "guides/board-operator/managing-agents", "guides/board-operator/org-structure", "guides/board-operator/managing-tasks", + "guides/board-operator/execution-workspaces-and-runtime-services", "guides/board-operator/delegation", "guides/board-operator/approvals", "guides/board-operator/costs-and-budgets", diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md new file mode 100644 index 00000000..285d701a --- /dev/null +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -0,0 +1,68 @@ +--- +title: Execution Workspaces And Runtime Services +summary: How project runtime configuration, execution workspaces, and issue runs fit together +--- + +This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip. + +## Project runtime configuration + +You can define how to run a project on the project workspace itself. + +- Project workspace runtime config describes how to run services for that project checkout. +- This is the default runtime configuration that child execution workspaces may inherit. +- Defining the config does not start anything by itself. + +## Manual runtime control + +Runtime services are manually controlled from the UI. + +- Project workspace runtime services are started and stopped from the project workspace UI. +- Execution workspace runtime services are started and stopped from the execution workspace UI. +- Paperclip does not automatically start or stop these runtime services as part of issue execution. +- Paperclip also does not automatically restart workspace runtime services on server boot. + +## Execution workspace inheritance + +Execution workspaces isolate code and runtime state from the project primary workspace. + +- An isolated execution workspace has its own checkout path, branch, and local runtime instance. +- The runtime configuration may inherit from the linked project workspace by default. +- The execution workspace may override that runtime configuration with its own workspace-specific settings. +- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace. + +## Issues and execution workspaces + +Issues are attached to execution workspace behavior, not to automatic runtime management. + +- An issue may create a new execution workspace when you choose an isolated workspace mode. +- An issue may reuse an existing execution workspace when you choose reuse. +- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services. +- Assigning or running an issue does not automatically start or stop runtime services for that workspace. + +## Execution workspace lifecycle + +Execution workspaces are durable until a human closes them. + +- The UI can archive an execution workspace. +- Closing an execution workspace stops its runtime services and cleans up its workspace artifacts when allowed. +- Shared workspaces that point at the project primary checkout are treated more conservatively during cleanup than disposable isolated workspaces. + +## Resolved workspace logic during heartbeat runs + +Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control. + +1. Heartbeat resolves a base workspace for the run. +2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed. +3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings. +4. Heartbeat passes the resolved code workspace to the agent run. +5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services. + +## Current implementation guarantees + +With the current implementation: + +- Project workspace runtime config is the fallback for execution workspace UI controls. +- Execution workspace runtime overrides are stored on the execution workspace. +- Heartbeat runs do not auto-start workspace runtime services. +- Server startup does not auto-restart workspace runtime services. diff --git a/package.json b/package.json index 9433fbeb..311a092f 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/dev-runner.mjs watch", - "dev:watch": "node scripts/dev-runner.mjs watch", - "dev:once": "node scripts/dev-runner.mjs dev", + "dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", + "dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", + "dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev", + "dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list", + "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", "build": "pnpm -r build", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 12989f72..4a5affdf 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record): Record, + options: { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; + } = {}, +): Record { + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} + export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); @@ -269,6 +296,10 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc return null; } +export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + return (await resolveCommandPath(command, cwd, env)) ?? command; +} + function quoteForCmd(arg: string) { if (!arg.length) return '""'; const escaped = arg.replace(/"/g, '""'); diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index b28ae180..41c0693f 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -26,7 +26,7 @@ Core fields: - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } -- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env +- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats Operational fields: - timeoutSec (number, optional): run timeout in seconds diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 8ac1d7ee..c7d6c6a8 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -14,10 +14,11 @@ import { buildPaperclipEnv, readPaperclipRuntimeSkillEntries, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -68,11 +69,13 @@ interface ClaudeExecutionInput { interface ClaudeRuntimeConfig { command: string; + resolvedCommand: string; cwd: string; workspaceId: string | null; workspaceRepoUrl: string | null; workspaceRepoRef: string | null; env: Record; + loggedEnv: Record; timeoutSec: number; graceSec: number; extraArgs: string[]; @@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { if (idx === args.length - 1 && value !== "-") return ``; return value; }), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index df339690..ed41754a 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -9,12 +9,13 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise ( index === args.length - 1 ? `` : value )), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index 195edfbf..1bdf66f2 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -31,7 +31,7 @@ Gateway connect identity fields: Request behavior fields: - payloadTemplate (object, optional): additional fields merged into gateway agent params -- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments +- workspaceRuntime (object, optional): reserved workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats - timeoutSec (number, optional): adapter timeout in seconds (default 120) - waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) - autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) @@ -45,7 +45,7 @@ Standard outbound payload additions: - paperclip (object): standardized Paperclip context added to every gateway agent request - paperclip.workspace (object, optional): resolved execution workspace for this run - paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run -- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace +- paperclip.workspaceRuntime (object, optional): reserved workspace runtime metadata when explicitly supplied outside normal heartbeat execution Standard result metadata supported: - meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index f3a5ae50..7c034c69 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -10,11 +10,12 @@ import { parseObject, buildPaperclipEnv, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, readPaperclipRuntimeSkillEntries, @@ -186,6 +187,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], - env: redactEnvForLogs(preparedRuntimeConfig.env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index a78bc1d4..b1d9d801 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -10,12 +10,13 @@ import { parseObject, buildPaperclipEnv, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -204,6 +205,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise | null; + runtimeConfig: ProjectWorkspaceRuntimeConfig | null; isPrimary: boolean; runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 47ed9494..2b2c4e2d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -31,6 +31,22 @@ export type ExecutionWorkspaceStatus = | "archived" | "cleanup_failed"; +export type ExecutionWorkspaceCloseReadinessState = + | "ready" + | "ready_with_warnings" + | "blocked"; + +export type ExecutionWorkspaceCloseActionKind = + | "archive_record" + | "stop_runtime_services" + | "cleanup_command" + | "teardown_command" + | "git_worktree_remove" + | "git_branch_delete" + | "remove_local_directory"; + +export type WorkspaceRuntimeDesiredState = "running" | "stopped"; + export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; baseRef?: string | null; @@ -40,6 +56,63 @@ export interface ExecutionWorkspaceStrategy { teardownCommand?: string | null; } +export interface ExecutionWorkspaceConfig { + provisionCommand: string | null; + teardownCommand: string | null; + cleanupCommand: string | null; + workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; +} + +export interface ProjectWorkspaceRuntimeConfig { + workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; +} + +export interface ExecutionWorkspaceCloseAction { + kind: ExecutionWorkspaceCloseActionKind; + label: string; + description: string; + command: string | null; +} + +export interface ExecutionWorkspaceCloseLinkedIssue { + id: string; + identifier: string | null; + title: string; + status: string; + isTerminal: boolean; +} + +export interface ExecutionWorkspaceCloseGitReadiness { + repoRoot: string | null; + workspacePath: string | null; + branchName: string | null; + baseRef: string | null; + hasDirtyTrackedFiles: boolean; + hasUntrackedFiles: boolean; + dirtyEntryCount: number; + untrackedEntryCount: number; + aheadCount: number | null; + behindCount: number | null; + isMergedIntoBase: boolean | null; + createdByRuntime: boolean; +} + +export interface ExecutionWorkspaceCloseReadiness { + workspaceId: string; + state: ExecutionWorkspaceCloseReadinessState; + blockingReasons: string[]; + warnings: string[]; + linkedIssues: ExecutionWorkspaceCloseLinkedIssue[]; + plannedActions: ExecutionWorkspaceCloseAction[]; + isDestructiveCloseAllowed: boolean; + isSharedWorkspace: boolean; + isProjectPrimaryWorkspace: boolean; + git: ExecutionWorkspaceCloseGitReadiness | null; + runtimeServices: WorkspaceRuntimeService[]; +} + export interface ProjectExecutionWorkspacePolicy { enabled: boolean; defaultMode?: ProjectExecutionWorkspaceDefaultMode; @@ -81,7 +154,9 @@ export interface ExecutionWorkspace { closedAt: Date | null; cleanupEligibleAt: Date | null; cleanupReason: string | null; + config: ExecutionWorkspaceConfig | null; metadata: Record | null; + runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 53a74036..9914d74e 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -8,10 +8,115 @@ export const executionWorkspaceStatusSchema = z.enum([ "cleanup_failed", ]); +export const executionWorkspaceConfigSchema = z.object({ + provisionCommand: z.string().optional().nullable(), + teardownCommand: z.string().optional().nullable(), + cleanupCommand: z.string().optional().nullable(), + workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), +}).strict(); + +export const executionWorkspaceCloseReadinessStateSchema = z.enum([ + "ready", + "ready_with_warnings", + "blocked", +]); + +export const executionWorkspaceCloseActionKindSchema = z.enum([ + "archive_record", + "stop_runtime_services", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + "remove_local_directory", +]); + +export const executionWorkspaceCloseActionSchema = z.object({ + kind: executionWorkspaceCloseActionKindSchema, + label: z.string(), + description: z.string(), + command: z.string().nullable(), +}).strict(); + +export const executionWorkspaceCloseLinkedIssueSchema = z.object({ + id: z.string().uuid(), + identifier: z.string().nullable(), + title: z.string(), + status: z.string(), + isTerminal: z.boolean(), +}).strict(); + +export const executionWorkspaceCloseGitReadinessSchema = z.object({ + repoRoot: z.string().nullable(), + workspacePath: z.string().nullable(), + branchName: z.string().nullable(), + baseRef: z.string().nullable(), + hasDirtyTrackedFiles: z.boolean(), + hasUntrackedFiles: z.boolean(), + dirtyEntryCount: z.number().int().nonnegative(), + untrackedEntryCount: z.number().int().nonnegative(), + aheadCount: z.number().int().nonnegative().nullable(), + behindCount: z.number().int().nonnegative().nullable(), + isMergedIntoBase: z.boolean().nullable(), + createdByRuntime: z.boolean(), +}).strict(); + +export const workspaceRuntimeServiceSchema = z.object({ + id: z.string(), + companyId: z.string().uuid(), + projectId: z.string().uuid().nullable(), + projectWorkspaceId: z.string().uuid().nullable(), + executionWorkspaceId: z.string().uuid().nullable(), + issueId: z.string().uuid().nullable(), + scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]), + scopeId: z.string().nullable(), + serviceName: z.string(), + status: z.enum(["starting", "running", "stopped", "failed"]), + lifecycle: z.enum(["shared", "ephemeral"]), + reuseKey: z.string().nullable(), + command: z.string().nullable(), + cwd: z.string().nullable(), + port: z.number().int().nullable(), + url: z.string().nullable(), + provider: z.enum(["local_process", "adapter_managed"]), + providerRef: z.string().nullable(), + ownerAgentId: z.string().uuid().nullable(), + startedByRunId: z.string().uuid().nullable(), + lastUsedAt: z.coerce.date(), + startedAt: z.coerce.date(), + stoppedAt: z.coerce.date().nullable(), + stopPolicy: z.record(z.unknown()).nullable(), + healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}).strict(); + +export const executionWorkspaceCloseReadinessSchema = z.object({ + workspaceId: z.string().uuid(), + state: executionWorkspaceCloseReadinessStateSchema, + blockingReasons: z.array(z.string()), + warnings: z.array(z.string()), + linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema), + plannedActions: z.array(executionWorkspaceCloseActionSchema), + isDestructiveCloseAllowed: z.boolean(), + isSharedWorkspace: z.boolean(), + isProjectPrimaryWorkspace: z.boolean(), + git: executionWorkspaceCloseGitReadinessSchema.nullable(), + runtimeServices: z.array(workspaceRuntimeServiceSchema), +}).strict(); + export const updateExecutionWorkspaceSchema = z.object({ + name: z.string().min(1).optional(), + cwd: z.string().optional().nullable(), + repoUrl: z.string().optional().nullable(), + baseRef: z.string().optional().nullable(), + branchName: z.string().optional().nullable(), + providerRef: z.string().optional().nullable(), status: executionWorkspaceStatusSchema.optional(), cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupReason: z.string().optional().nullable(), + config: executionWorkspaceConfigSchema.optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), }).strict(); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 1ab21793..2c8a5c84 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -111,6 +111,7 @@ export { createProjectWorkspaceSchema, updateProjectWorkspaceSchema, projectExecutionWorkspacePolicySchema, + projectWorkspaceRuntimeConfigSchema, type CreateProject, type UpdateProject, type CreateProjectWorkspace, @@ -153,8 +154,15 @@ export { } from "./work-product.js"; export { + executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, + executionWorkspaceCloseActionKindSchema, + executionWorkspaceCloseActionSchema, + executionWorkspaceCloseGitReadinessSchema, + executionWorkspaceCloseLinkedIssueSchema, + executionWorkspaceCloseReadinessSchema, + executionWorkspaceCloseReadinessStateSchema, type UpdateExecutionWorkspace, } from "./execution-workspace.js"; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index cf5aba8a..89308ff4 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -27,6 +27,11 @@ export const projectExecutionWorkspacePolicySchema = z }) .strict(); +export const projectWorkspaceRuntimeConfigSchema = z.object({ + workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), +}).strict(); + const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]); @@ -44,6 +49,7 @@ const projectWorkspaceFields = { remoteWorkspaceRef: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), + runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), }; function validateProjectWorkspace(value: Record, ctx: z.RefinementCtx) { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts new file mode 100644 index 00000000..aed49c1b --- /dev/null +++ b/scripts/dev-runner.ts @@ -0,0 +1,656 @@ +#!/usr/bin/env -S node --import tsx +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; +import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; +import { + findAdoptableLocalService, + removeLocalServiceRegistryRecord, + touchLocalServiceRegistryRecord, + writeLocalServiceRegistryRecord, +} from "../server/src/services/local-service-supervisor.ts"; + +const mode = process.argv[2] === "watch" ? "watch" : "dev"; +const cliArgs = process.argv.slice(3); +const scanIntervalMs = 1500; +const autoRestartPollIntervalMs = 2500; +const gracefulShutdownTimeoutMs = 10_000; +const changedPathSampleLimit = 5; +const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); + +const watchedDirectories = [ + "cli", + "scripts", + "server", + "packages/adapter-utils", + "packages/adapters", + "packages/db", + "packages/plugins/sdk", + "packages/shared", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const watchedFiles = [ + ".env", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.base.json", + "tsconfig.json", + "vitest.config.ts", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const ignoredDirectoryNames = new Set([ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "node_modules", + "ui-dist", +]); + +const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-status.json", +]); + +const tailscaleAuthFlagNames = new Set([ + "--tailscale-auth", + "--authenticated-private", +]); + +let tailscaleAuth = false; +const forwardedArgs: string[] = []; + +for (const arg of cliArgs) { + if (tailscaleAuthFlagNames.has(arg)) { + tailscaleAuth = true; + continue; + } + forwardedArgs.push(arg); +} + +if (process.env.npm_config_tailscale_auth === "true") { + tailscaleAuth = true; +} +if (process.env.npm_config_authenticated_private === "true") { + tailscaleAuth = true; +} + +const env: NodeJS.ProcessEnv = { + ...process.env, + PAPERCLIP_UI_DEV_MIDDLEWARE: "true", +}; + +if (mode === "dev") { + env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; +} + +if (mode === "watch") { + env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; + env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; +} + +if (tailscaleAuth) { + env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; + env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private"; + env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto"; + env.HOST = "0.0.0.0"; + console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0"); +} else { + console.log("[paperclip] dev mode: local_trusted (default)"); +} + +const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100; +const devService = createDevServiceIdentity({ + mode, + forwardedArgs, + tailscaleAuth, + port: serverPort, +}); + +const existingRunner = await findAdoptableLocalService({ + serviceKey: devService.serviceKey, + cwd: repoRoot, + envFingerprint: devService.envFingerprint, + port: serverPort, +}); +if (existingRunner) { + console.log( + `[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`, + ); + process.exit(0); +} + +const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +let previousSnapshot = collectWatchedSnapshot(); +let dirtyPaths = new Set(); +let pendingMigrations: string[] = []; +let lastChangedAt: string | null = null; +let lastRestartAt: string | null = null; +let scanInFlight = false; +let restartInFlight = false; +let shuttingDown = false; +let childExitWasExpected = false; +let child: ReturnType | null = null; +let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null; +let scanTimer: ReturnType | null = null; +let autoRestartTimer: ReturnType | null = null; + +function toError(error: unknown, context = "Dev runner command failed") { + if (error instanceof Error) return error; + if (error === undefined) return new Error(context); + if (typeof error === "string") return new Error(`${context}: ${error}`); + + try { + return new Error(`${context}: ${JSON.stringify(error)}`); + } catch { + return new Error(`${context}: ${String(error)}`); + } +} + +process.on("uncaughtException", async (error) => { + await removeLocalServiceRegistryRecord(devService.serviceKey); + const err = toError(error, "Uncaught exception in dev runner"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); +}); + +process.on("unhandledRejection", async (reason) => { + await removeLocalServiceRegistryRecord(devService.serviceKey); + const err = toError(reason, "Unhandled promise rejection in dev runner"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); +}); + +function formatPendingMigrationSummary(migrations: string[]) { + if (migrations.length === 0) return "none"; + return migrations.length > 3 + ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` + : migrations.join(", "); +} + +function exitForSignal(signal: NodeJS.Signals) { + if (signal === "SIGINT") { + process.exit(130); + } + if (signal === "SIGTERM") { + process.exit(143); + } + process.exit(1); +} + +function toRelativePath(absolutePath: string) { + return path.relative(repoRoot, absolutePath).split(path.sep).join("/"); +} + +function readSignature(absolutePath: string) { + const stats = statSync(absolutePath); + return `${Math.trunc(stats.mtimeMs)}:${stats.size}`; +} + +function addFileToSnapshot(snapshot: Map, absolutePath: string) { + const relativePath = toRelativePath(absolutePath); + if (ignoredRelativePaths.has(relativePath)) return; + if (!shouldTrackDevServerPath(relativePath)) return; + snapshot.set(relativePath, readSignature(absolutePath)); +} + +function walkDirectory(snapshot: Map, absoluteDirectory: string) { + if (!existsSync(absoluteDirectory)) return; + + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if (ignoredDirectoryNames.has(entry.name)) continue; + + const absolutePath = path.join(absoluteDirectory, entry.name); + if (entry.isDirectory()) { + walkDirectory(snapshot, absolutePath); + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) { + addFileToSnapshot(snapshot, absolutePath); + } + } +} + +function collectWatchedSnapshot() { + const snapshot = new Map(); + + for (const absoluteDirectory of watchedDirectories) { + walkDirectory(snapshot, absoluteDirectory); + } + for (const absoluteFile of watchedFiles) { + if (!existsSync(absoluteFile)) continue; + addFileToSnapshot(snapshot, absoluteFile); + } + + return snapshot; +} + +function diffSnapshots(previous: Map, next: Map) { + const changed = new Set(); + + for (const [relativePath, signature] of next) { + if (previous.get(relativePath) !== signature) { + changed.add(relativePath); + } + } + for (const relativePath of previous.keys()) { + if (!next.has(relativePath)) { + changed.add(relativePath); + } + } + + return [...changed].sort(); +} + +function ensureDevStatusDirectory() { + mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true }); +} + +function writeDevServerStatus() { + if (mode !== "dev") return; + + ensureDevStatusDirectory(); + const changedPaths = [...dirtyPaths].sort(); + writeFileSync( + devServerStatusFilePath, + `${JSON.stringify({ + dirty: changedPaths.length > 0 || pendingMigrations.length > 0, + lastChangedAt, + changedPathCount: changedPaths.length, + changedPathsSample: changedPaths.slice(0, changedPathSampleLimit), + pendingMigrations, + lastRestartAt, + }, null, 2)}\n`, + "utf8", + ); +} + +function clearDevServerStatus() { + if (mode !== "dev") return; + rmSync(devServerStatusFilePath, { force: true }); +} + +async function updateDevServiceRecord(extra?: Record) { + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: devService.serviceKey, + profileKind: "paperclip-dev", + serviceName: devService.serviceName, + command: "dev-runner.ts", + cwd: repoRoot, + envFingerprint: devService.envFingerprint, + port: serverPort, + url: `http://127.0.0.1:${serverPort}`, + pid: process.pid, + processGroupId: null, + provider: "local_process", + runtimeServiceId: null, + reuseKey: null, + startedAt: lastRestartAt ?? new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { + repoRoot, + mode, + childPid: child?.pid ?? null, + url: `http://127.0.0.1:${serverPort}`, + ...extra, + }, + }); +} + +async function runPnpm(args: string[], options: { + stdio?: "inherit" | ["ignore", "pipe", "pipe"]; + env?: NodeJS.ProcessEnv; + cwd?: string; +} = {}) { + return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => { + const spawned = spawn(pnpmBin, args, { + stdio: options.stdio ?? ["ignore", "pipe", "pipe"], + env: options.env ?? process.env, + cwd: options.cwd, + shell: process.platform === "win32", + }); + + let stdoutBuffer = ""; + let stderrBuffer = ""; + + if (spawned.stdout) { + spawned.stdout.on("data", (chunk) => { + stdoutBuffer += String(chunk); + }); + } + if (spawned.stderr) { + spawned.stderr.on("data", (chunk) => { + stderrBuffer += String(chunk); + }); + } + + spawned.on("error", reject); + spawned.on("exit", (code, signal) => { + resolve({ + code: code ?? 0, + signal, + stdout: stdoutBuffer, + stderr: stderrBuffer, + }); + }); + }); +} + +async function getMigrationStatusPayload() { + const status = await runPnpm( + ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], + { env }, + ); + if (status.code !== 0) { + process.stderr.write( + status.stderr || + status.stdout || + `[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`, + ); + process.exit(status.code); + } + + try { + return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] }; + } catch (error) { + process.stderr.write( + status.stderr || + status.stdout || + "[paperclip] migration-status returned invalid JSON payload\n", + ); + throw toError(error, "Unable to parse migration-status JSON output"); + } +} + +async function refreshPendingMigrations() { + const payload = await getMigrationStatusPayload(); + pendingMigrations = + payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations) + ? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0) + : []; + writeDevServerStatus(); + return payload; +} + +async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) { + const interactive = options.interactive ?? mode === "watch"; + const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true"; + const exitOnDecline = options.exitOnDecline ?? mode === "watch"; + + const payload = await refreshPendingMigrations(); + if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) { + return; + } + + let shouldApply = autoApply; + + if (!autoApply && interactive) { + if (!stdin.isTTY || !stdout.isTTY) { + shouldApply = true; + } else { + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = ( + await prompt.question( + `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `, + ) + ) + .trim() + .toLowerCase(); + shouldApply = answer === "y" || answer === "yes"; + } finally { + prompt.close(); + } + } + } + + if (!shouldApply) { + if (exitOnDecline) { + process.stderr.write( + `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`, + ); + process.exit(1); + } + return; + } + + const exit = await runPnpm(["db:migrate"], { + stdio: "inherit", + env, + cwd: repoRoot, + }); + if (exit.signal) { + exitForSignal(exit.signal); + return; + } + if (exit.code !== 0) { + process.exit(exit.code); + } + + await refreshPendingMigrations(); +} + +async function buildPluginSdk() { + console.log("[paperclip] building plugin sdk..."); + const result = await runPnpm( + ["--filter", "@paperclipai/plugin-sdk", "build"], + { stdio: "inherit" }, + ); + if (result.signal) { + exitForSignal(result.signal); + return; + } + if (result.code !== 0) { + console.error("[paperclip] plugin sdk build failed"); + process.exit(result.code); + } +} + +async function markChildAsCurrent() { + previousSnapshot = collectWatchedSnapshot(); + dirtyPaths = new Set(); + lastChangedAt = null; + lastRestartAt = new Date().toISOString(); + await refreshPendingMigrations(); + await updateDevServiceRecord(); +} + +async function scanForBackendChanges() { + if (mode !== "dev" || scanInFlight || restartInFlight) return; + scanInFlight = true; + try { + const nextSnapshot = collectWatchedSnapshot(); + const changed = diffSnapshots(previousSnapshot, nextSnapshot); + previousSnapshot = nextSnapshot; + if (changed.length === 0) return; + + for (const relativePath of changed) { + dirtyPaths.add(relativePath); + } + lastChangedAt = new Date().toISOString(); + await refreshPendingMigrations(); + } finally { + scanInFlight = false; + } +} + +async function getDevHealthPayload() { + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + if (!response.ok) { + throw new Error(`Health request failed (${response.status})`); + } + return await response.json(); +} + +async function waitForChildExit() { + if (!childExitPromise) { + return { code: 0, signal: null }; + } + return await childExitPromise; +} + +async function stopChildForRestart() { + if (!child) return { code: 0, signal: null }; + childExitWasExpected = true; + child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + if (child) { + child.kill("SIGKILL"); + } + }, gracefulShutdownTimeoutMs); + try { + return await waitForChildExit(); + } finally { + clearTimeout(killTimer); + } +} + +async function startServerChild() { + await buildPluginSdk(); + + const serverScript = mode === "watch" ? "dev:watch" : "dev"; + child = spawn( + pnpmBin, + ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], + { stdio: "inherit", env, shell: process.platform === "win32" }, + ); + + childExitPromise = new Promise((resolve, reject) => { + child?.on("error", reject); + child?.on("exit", (code, signal) => { + const expected = childExitWasExpected; + childExitWasExpected = false; + child = null; + childExitPromise = null; + void touchLocalServiceRegistryRecord(devService.serviceKey, { + metadata: { + repoRoot, + mode, + childPid: null, + url: `http://127.0.0.1:${serverPort}`, + }, + }); + resolve({ code: code ?? 0, signal }); + + if (restartInFlight || expected || shuttingDown) { + return; + } + if (signal) { + exitForSignal(signal); + return; + } + process.exit(code ?? 0); + }); + }); + + await markChildAsCurrent(); +} + +async function maybeAutoRestartChild() { + if (mode !== "dev" || restartInFlight || !child) return; + if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + + restartInFlight = true; + let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null; + try { + health = await getDevHealthPayload(); + } catch { + restartInFlight = false; + return; + } + + const devServer = health?.devServer; + if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { + restartInFlight = false; + return; + } + if ((devServer.activeRunCount ?? 0) > 0) { + restartInFlight = false; + return; + } + + try { + await maybePreflightMigrations({ + autoApply: true, + interactive: false, + exitOnDecline: false, + }); + await stopChildForRestart(); + await startServerChild(); + } catch (error) { + const err = toError(error, "Auto-restart failed"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); + } finally { + restartInFlight = false; + } +} + +function installDevIntervals() { + if (mode !== "dev") return; + + scanTimer = setInterval(() => { + void scanForBackendChanges(); + }, scanIntervalMs); + autoRestartTimer = setInterval(() => { + void maybeAutoRestartChild(); + }, autoRestartPollIntervalMs); +} + +function clearDevIntervals() { + if (scanTimer) { + clearInterval(scanTimer); + scanTimer = null; + } + if (autoRestartTimer) { + clearInterval(autoRestartTimer); + autoRestartTimer = null; + } +} + +async function shutdown(signal: NodeJS.Signals) { + if (shuttingDown) return; + shuttingDown = true; + clearDevIntervals(); + clearDevServerStatus(); + await removeLocalServiceRegistryRecord(devService.serviceKey); + + if (!child) { + exitForSignal(signal); + return; + } + + childExitWasExpected = true; + child.kill(signal); + const exit = await waitForChildExit(); + if (exit.signal) { + exitForSignal(exit.signal); + return; + } + process.exit(exit.code ?? 0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +await maybePreflightMigrations(); +await startServerChild(); +installDevIntervals(); + +if (mode === "watch") { + const exit = await waitForChildExit(); + await removeLocalServiceRegistryRecord(devService.serviceKey); + if (exit.signal) { + exitForSignal(exit.signal); + } + process.exit(exit.code ?? 0); +} diff --git a/scripts/dev-service-profile.ts b/scripts/dev-service-profile.ts new file mode 100644 index 00000000..9c129b34 --- /dev/null +++ b/scripts/dev-service-profile.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createLocalServiceKey } from "../server/src/services/local-service-supervisor.ts"; + +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function createDevServiceIdentity(input: { + mode: "watch" | "dev"; + forwardedArgs: string[]; + tailscaleAuth: boolean; + port: number; +}) { + const envFingerprint = createHash("sha256") + .update( + JSON.stringify({ + mode: input.mode, + forwardedArgs: input.forwardedArgs, + tailscaleAuth: input.tailscaleAuth, + port: input.port, + }), + ) + .digest("hex"); + + const serviceName = input.mode === "watch" ? "paperclip-dev-watch" : "paperclip-dev-once"; + const serviceKey = createLocalServiceKey({ + profileKind: "paperclip-dev", + serviceName, + cwd: repoRoot, + command: "dev-runner.ts", + envFingerprint, + port: input.port, + scope: { + repoRoot, + mode: input.mode, + }, + }); + + return { + serviceKey, + serviceName, + envFingerprint, + }; +} diff --git a/scripts/dev-service.ts b/scripts/dev-service.ts new file mode 100644 index 00000000..978607ec --- /dev/null +++ b/scripts/dev-service.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S node --import tsx +import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts"; +import { repoRoot } from "./dev-service-profile.ts"; + +function toDisplayLines(records: Awaited>) { + return records.map((record) => { + const childPid = typeof record.metadata?.childPid === "number" ? ` child=${record.metadata.childPid}` : ""; + const url = typeof record.metadata?.url === "string" ? ` url=${record.metadata.url}` : ""; + return `${record.serviceName} pid=${record.pid}${childPid} cwd=${record.cwd}${url}`; + }); +} + +const command = process.argv[2] ?? "list"; +const records = await listLocalServiceRegistryRecords({ + profileKind: "paperclip-dev", + metadata: { repoRoot }, +}); + +if (command === "list") { + if (records.length === 0) { + console.log("No Paperclip dev services registered for this repo."); + process.exit(0); + } + for (const line of toDisplayLines(records)) { + console.log(line); + } + process.exit(0); +} + +if (command === "stop") { + if (records.length === 0) { + console.log("No Paperclip dev services registered for this repo."); + process.exit(0); + } + for (const record of records) { + await terminateLocalService(record); + await removeLocalServiceRegistryRecord(record.serviceKey); + console.log(`Stopped ${record.serviceName} (pid ${record.pid})`); + } + process.exit(0); +} + +console.error(`Unknown dev-service command: ${command}`); +process.exit(1); diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh index 2cb946e2..9a53498a 100755 --- a/scripts/kill-dev.sh +++ b/scripts/kill-dev.sh @@ -8,64 +8,199 @@ # set -euo pipefail +shopt -s nullglob DRY_RUN=false if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then DRY_RUN=true fi -# Collect PIDs of node processes running from any paperclip directory. -# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... -# Excludes postgres-related processes. -pids=() -lines=() +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_PARENT="$(dirname "$REPO_ROOT")" + +node_pids=() +node_lines=() +pg_pids=() +pg_pidfiles=() +pg_data_dirs=() + +is_pid_running() { + local pid="$1" + kill -0 "$pid" 2>/dev/null +} + +read_pidfile_pid() { + local pidfile="$1" + local first_line + first_line="$(head -n 1 "$pidfile" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$first_line" =~ ^[0-9]+$ ]] && (( first_line > 0 )); then + printf '%s\n' "$first_line" + return 0 + fi + return 1 +} + +command_for_pid() { + local pid="$1" + ps -o command= -p "$pid" 2>/dev/null || true +} + +append_postgres_from_pidfile() { + local pidfile="$1" + local pid cmd data_dir + pid="$(read_pidfile_pid "$pidfile" || true)" + [[ -n "$pid" ]] || return 0 + is_pid_running "$pid" || return 0 + cmd="$(command_for_pid "$pid")" + [[ "$cmd" == *postgres* ]] || return 0 + + for existing_pid in "${pg_pids[@]:-}"; do + [[ "$existing_pid" == "$pid" ]] && return 0 + done + + data_dir="$(dirname "$pidfile")" + pg_pids+=("$pid") + pg_pidfiles+=("$pidfile") + pg_data_dirs+=("$data_dir") +} + +wait_for_pid_exit() { + local pid="$1" + local timeout_sec="$2" + local waited=0 + while is_pid_running "$pid"; do + if (( waited >= timeout_sec * 10 )); then + return 1 + fi + sleep 0.1 + ((waited += 1)) + done + return 0 +} while IFS= read -r line; do [[ -z "$line" ]] && continue - # skip postgres processes [[ "$line" == *postgres* ]] && continue pid=$(echo "$line" | awk '{print $2}') - pids+=("$pid") - lines+=("$line") + node_pids+=("$pid") + node_lines+=("$line") done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) -if [[ ${#pids[@]} -eq 0 ]]; then +candidate_pidfiles=() +candidate_pidfiles+=( + "$HOME"/.paperclip/instances/*/db/postmaster.pid + "$REPO_ROOT"/.paperclip/instances/*/db/postmaster.pid + "$REPO_ROOT"/.paperclip/runtime-services/instances/*/db/postmaster.pid +) + +for sibling_root in "$REPO_PARENT"/paperclip*; do + [[ -d "$sibling_root" ]] || continue + candidate_pidfiles+=( + "$sibling_root"/.paperclip/instances/*/db/postmaster.pid + "$sibling_root"/.paperclip/runtime-services/instances/*/db/postmaster.pid + ) +done + +for pidfile in "${candidate_pidfiles[@]:-}"; do + [[ -f "$pidfile" ]] || continue + append_postgres_from_pidfile "$pidfile" +done + +if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then echo "No Paperclip dev processes found." exit 0 fi -echo "Found ${#pids[@]} Paperclip dev process(es):" -echo "" +if [[ ${#node_pids[@]} -gt 0 ]]; then + echo "Found ${#node_pids[@]} Paperclip dev node process(es):" + echo "" -for i in "${!pids[@]}"; do - line="${lines[$i]}" - pid=$(echo "$line" | awk '{print $2}') - start=$(echo "$line" | awk '{print $9}') - cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') - # Shorten the command for readability - cmd=$(echo "$cmd" | sed "s|$HOME/||g") - printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" -done + for i in "${!node_pids[@]:-}"; do + line="${node_lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" + done -echo "" + echo "" +fi + +if [[ ${#pg_pids[@]} -gt 0 ]]; then + echo "Found ${#pg_pids[@]} embedded PostgreSQL master process(es):" + echo "" + + for i in "${!pg_pids[@]:-}"; do + pid="${pg_pids[$i]}" + data_dir="${pg_data_dirs[$i]}" + pidfile="${pg_pidfiles[$i]}" + short_data_dir="${data_dir/#$HOME\//}" + short_pidfile="${pidfile/#$HOME\//}" + printf " PID %-7s data %-55s pidfile %s\n" "$pid" "$short_data_dir" "$short_pidfile" + done + + echo "" +fi if [[ "$DRY_RUN" == true ]]; then echo "Dry run — re-run without --dry to kill these processes." exit 0 fi -echo "Sending SIGTERM..." -for pid in "${pids[@]}"; do - kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" -done +if [[ ${#node_pids[@]} -gt 0 ]]; then + echo "Sending SIGTERM to Paperclip node processes..." + for pid in "${node_pids[@]}"; do + kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone" + done + echo "Waiting briefly for node processes to exit..." + sleep 2 +fi -# Give processes a moment to exit, then SIGKILL any stragglers -sleep 2 -for pid in "${pids[@]}"; do - if kill -0 "$pid" 2>/dev/null; then - echo " $pid still alive, sending SIGKILL..." - kill -9 "$pid" 2>/dev/null || true +leftover_pg_pids=() +leftover_pg_data_dirs=() +for i in "${!pg_pids[@]:-}"; do + pid="${pg_pids[$i]}" + if is_pid_running "$pid"; then + leftover_pg_pids+=("$pid") + leftover_pg_data_dirs+=("${pg_data_dirs[$i]}") fi done +if [[ ${#leftover_pg_pids[@]} -gt 0 ]]; then + echo "Sending SIGTERM to leftover embedded PostgreSQL processes..." + for i in "${!leftover_pg_pids[@]:-}"; do + pid="${leftover_pg_pids[$i]}" + data_dir="${leftover_pg_data_dirs[$i]}" + kill -TERM "$pid" 2>/dev/null \ + && echo " signaled $pid ($data_dir)" \ + || echo " $pid already gone" + done + echo "Waiting up to 15s for PostgreSQL to shut down cleanly..." + for pid in "${leftover_pg_pids[@]:-}"; do + if wait_for_pid_exit "$pid" 15; then + echo " postgres $pid exited cleanly" + fi + done +fi + +if [[ ${#node_pids[@]} -gt 0 ]]; then + for pid in "${node_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " node $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi + done +fi + +if [[ ${#pg_pids[@]} -gt 0 ]]; then + for pid in "${pg_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " postgres $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi + done +fi + echo "Done." diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts new file mode 100644 index 00000000..be933bca --- /dev/null +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execute } from "@paperclipai/adapter-claude-local/server"; + +async function writeFakeClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +describe("claude execute", () => { + it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath = path.join(root, "capture.json"); + const claudeConfigDir = path.join(root, "claude-config"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(claudeConfigDir, { recursive: true }); + await writeFakeClaudeCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index b83e3db7..4f584435 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -195,6 +195,70 @@ describe("codex execute", () => { } }); + it("logs HOME and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "codex", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts new file mode 100644 index 00000000..d4a50bdc --- /dev/null +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -0,0 +1,325 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + executionWorkspaceService, + mergeExecutionWorkspaceConfig, + readExecutionWorkspaceConfig, +} from "../services/execution-workspaces.ts"; + +const execFileAsync = promisify(execFile); + +describe("execution workspace config helpers", () => { + it("reads typed config from persisted metadata", () => { + expect(readExecutionWorkspaceConfig({ + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }, + })).toEqual({ + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + desiredState: null, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }); + }); + + it("merges config patches without dropping unrelated metadata", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + cleanupCommand: "pkill -f vite || true", + }, + }, + { + teardownCommand: "bash ./scripts/teardown-worktree.sh", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + )).toEqual({ + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + desiredState: null, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + }); + }); + + it("clears the nested config block when requested", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + null, + )).toEqual({ + source: "project_primary", + }); + }); +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createTempRepo() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-")); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["branch", "-M", "main"]); + return repoRoot; +} + +describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const tempDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-"); + db = createDb(tempDb.connectionString); + svc = executionWorkspaceService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(companies); + + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + 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", + }); + 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", + metadata: { + config: { + teardownCommand: "bash ./scripts/teardown.sh", + }, + }, + }); + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + title: "Still working", + status: "todo", + priority: "medium", + executionWorkspaceId, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "ready_with_warnings", + isSharedWorkspace: true, + isProjectPrimaryWorkspace: true, + isDestructiveCloseAllowed: true, + }); + expect(readiness?.blockingReasons).toEqual([]); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.", + "This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.", + ])); + }); + + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { + const repoRoot = await createTempRepo(); + tempDirs.add(repoRoot); + const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`); + tempDirs.add(worktreePath); + + await runGit(repoRoot, ["branch", "paperclip-close-check"]); + await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]); + await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8"); + await runGit(worktreePath, ["add", "feature.txt"]); + await runGit(worktreePath, ["commit", "-m", "Feature commit"]); + await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8"); + + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + 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, + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "bash ./scripts/project-teardown.sh", + }, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "git_repo", + isPrimary: true, + cwd: repoRoot, + cleanupCommand: "printf 'project cleanup\\n'", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + metadata: { + createdByRuntime: true, + config: { + cleanupCommand: "printf 'workspace cleanup\\n'", + }, + }, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "ready_with_warnings", + isSharedWorkspace: false, + isProjectPrimaryWorkspace: false, + isDestructiveCloseAllowed: true, + git: { + workspacePath: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + createdByRuntime: true, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: true, + aheadCount: 1, + behindCount: 0, + isMergedIntoBase: false, + }, + }); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "The workspace has 1 untracked file.", + "This workspace is 1 commit ahead of main and is not merged.", + ])); + expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([ + "archive_record", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + ])); + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 7fab2b42..ca23d907 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3,11 +3,13 @@ import type { agents } from "@paperclipai/db"; import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + applyPersistedExecutionWorkspaceConfig, buildExplicitResumeSessionOverride, formatRuntimeWorkspaceWarningLog, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, + stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; @@ -120,6 +122,64 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => { }); }); +describe("applyPersistedExecutionWorkspaceConfig", () => { + it("does not add workspace runtime when only the project workspace had manual runtime config", () => { + const result = applyPersistedExecutionWorkspaceConfig({ + config: {}, + workspaceConfig: null, + mode: "isolated_workspace", + }); + + expect("workspaceRuntime" in result).toBe(false); + }); + + it("applies explicit persisted execution workspace runtime config when present", () => { + const result = applyPersistedExecutionWorkspaceConfig({ + config: {}, + workspaceConfig: { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + desiredState: null, + workspaceRuntime: { + services: [{ name: "workspace-web" }], + }, + }, + mode: "isolated_workspace", + }); + + expect(result.workspaceRuntime).toEqual({ + services: [{ name: "workspace-web" }], + }); + }); +}); + +describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { + it("removes workspace runtime before heartbeat execution", () => { + const input = { + cwd: "/tmp/project", + workspaceStrategy: { + type: "git_worktree", + }, + workspaceRuntime: { + services: [{ name: "web" }], + }, + }; + + const result = stripWorkspaceRuntimeFromExecutionRunConfig(input); + + expect(result).toEqual({ + cwd: "/tmp/project", + workspaceStrategy: { + type: "git_worktree", + }, + }); + expect(input.workspaceRuntime).toEqual({ + services: [{ name: "web" }], + }); + }); +}); + describe("shouldResetTaskSessionForWake", () => { it("resets session context on assignment wake", () => { expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index c5aef4b3..ee79d514 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -5,9 +5,11 @@ import { agents, companies, createDb, + executionWorkspaces, issueComments, issueInboxArchives, issues, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -40,6 +42,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(issueInboxArchives); await db.delete(activityLog); await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projects); await db.delete(agents); await db.delete(companies); }); @@ -219,6 +223,86 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); }); + it("filters issues by execution workspace id", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const targetWorkspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const linkedIssueId = randomUUID(); + const otherLinkedIssueId = randomUUID(); + const unlinkedIssueId = 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: "Workspace project", + status: "in_progress", + }); + + await db.insert(executionWorkspaces).values([ + { + id: targetWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Target workspace", + status: "active", + providerType: "local_fs", + }, + { + id: otherWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Other workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values([ + { + id: linkedIssueId, + companyId, + projectId, + title: "Linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: targetWorkspaceId, + }, + { + id: otherLinkedIssueId, + companyId, + projectId, + title: "Other linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: otherWorkspaceId, + }, + { + id: unlinkedIssueId, + companyId, + projectId, + title: "Unlinked issue", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId }); + + expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]); + }); + it("hides archived inbox issues until new external activity arrives", async () => { const companyId = randomUUID(); const userId = "user-1"; diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6a55a72b..1af2eea1 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1,25 +1,51 @@ 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, + executionWorkspaces, + heartbeatRuns, + projects, + workspaceRuntimeServices, +} from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import { cleanupExecutionWorkspaceArtifacts, ensureRuntimeServicesForRun, normalizeAdapterManagedRuntimeServices, + reconcilePersistedRuntimeServicesOnStartup, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + resetRuntimeServicesForTests, + sanitizeRuntimeServiceBaseEnv, 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(); +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 +154,28 @@ afterEach(async () => { delete process.env.PAPERCLIP_INSTANCE_ID; delete process.env.PAPERCLIP_WORKTREES_DIR; delete process.env.DATABASE_URL; + await resetRuntimeServicesForTests(); +}); + +describe("sanitizeRuntimeServiceBaseEnv", () => { + it("removes inherited Paperclip and pnpm auth flags before spawning runtime services", () => { + const sanitized = sanitizeRuntimeServiceBaseEnv({ + PATH: process.env.PATH, + DATABASE_URL: "postgres://example.test/paperclip", + PAPERCLIP_HOME: "/tmp/paperclip-home", + PAPERCLIP_INSTANCE_ID: "runtime-instance", + npm_config_tailscale_auth: "true", + npm_config_authenticated_private: "true", + HOST: "0.0.0.0", + }); + + expect(sanitized.PAPERCLIP_HOME).toBeUndefined(); + expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined(); + expect(sanitized.DATABASE_URL).toBeUndefined(); + expect(sanitized.npm_config_tailscale_auth).toBeUndefined(); + expect(sanitized.npm_config_authenticated_private).toBeUndefined(); + expect(sanitized.HOST).toBe("0.0.0.0"); + }); }); describe("realizeExecutionWorkspace", () => { @@ -834,6 +882,101 @@ describe("ensureRuntimeServicesForRun", () => { expect(third[0]?.id).not.toBe(first[0]?.id); }); + it("does not reuse project-scoped shared services across different workspace launch contexts", async () => { + const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-")); + const worktreeWorkspaceRoot = path.join(primaryWorkspaceRoot, ".paperclip", "worktrees", "PAP-874-chat-speed-issues"); + await fs.mkdir(worktreeWorkspaceRoot, { recursive: true }); + + const primaryWorkspace = buildWorkspace(primaryWorkspaceRoot); + const executionWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(worktreeWorkspaceRoot), + source: "task_session", + strategy: "git_worktree", + cwd: worktreeWorkspaceRoot, + branchName: "PAP-874-chat-speed-issues", + worktreePath: worktreeWorkspaceRoot, + }; + const serviceCommand = + "node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\""; + const config = { + workspaceRuntime: { + services: [ + { + name: "paperclip-dev", + command: serviceCommand, + cwd: ".", + env: { + PAPERCLIP_HOME: "{{workspace.cwd}}/.paperclip/runtime-services", + }, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "project_workspace", + stopPolicy: { + type: "on_run_finish", + }, + }, + ], + }, + }; + + const primaryRunId = "run-project-workspace"; + const executionRunId = "run-execution-workspace"; + leasedRunIds.add(primaryRunId); + leasedRunIds.add(executionRunId); + + const primaryServices = await ensureRuntimeServicesForRun({ + runId: primaryRunId, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace: primaryWorkspace, + config, + adapterEnv: {}, + }); + + const executionServices = await ensureRuntimeServicesForRun({ + runId: executionRunId, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace: executionWorkspace, + executionWorkspaceId: "execution-workspace-1", + config, + adapterEnv: {}, + }); + + expect(primaryServices).toHaveLength(1); + expect(executionServices).toHaveLength(1); + expect(primaryServices[0]?.reused).toBe(false); + expect(executionServices[0]?.reused).toBe(false); + expect(executionServices[0]?.id).not.toBe(primaryServices[0]?.id); + expect(executionServices[0]?.executionWorkspaceId).toBe("execution-workspace-1"); + expect(executionServices[0]?.cwd).toBe(worktreeWorkspaceRoot); + expect(executionServices[0]?.url).not.toBe(primaryServices[0]?.url); + + const primaryResponse = await fetch(primaryServices[0]!.url!); + expect(await primaryResponse.text()).toBe(path.join(primaryWorkspaceRoot, ".paperclip", "runtime-services")); + + const executionResponse = await fetch(executionServices[0]!.url!); + expect(await executionResponse.text()).toBe(path.join(worktreeWorkspaceRoot, ".paperclip", "runtime-services")); + }); + it("does not leak parent Paperclip instance env into runtime service commands", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-")); const workspace = buildWorkspace(workspaceRoot); @@ -1028,6 +1171,258 @@ describe("ensureRuntimeServicesForRun", () => { }); }); +describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { + let db!: ReturnType; + let tempDb: Awaited> | 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(executionWorkspaces); + await db.delete(projects); + 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(); + }); + + it("persists controlled execution workspace stops as stopped", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = 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(projects).values({ + id: projectId, + companyId, + name: "Runtime stop test", + status: "active", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace stop test", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + }); + 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, + executionWorkspaceId, + 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: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services[0]?.url).toBeTruthy(); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspace.cwd, + }); + await releaseRuntimeServicesForRun(runId); + leasedRunIds.delete(runId); + await new Promise((resolve) => setTimeout(resolve, 250)); + + await expect(fetch(services[0]!.url!)).rejects.toThrow(); + + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, services[0]!.id)) + .then((rows) => rows[0] ?? null); + + expect(persisted?.status).toBe("stopped"); + expect(persisted?.healthStatus).toBe("unknown"); + expect(persisted?.stoppedAt).toBeTruthy(); + }); +}); + describe("normalizeAdapterManagedRuntimeServices", () => { it("fills workspace defaults and derives stable ids for adapter-managed services", () => { const workspace = buildWorkspace("/tmp/project"); diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 1d0cf9f6..ff2bf82e 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -5,7 +5,9 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, + ensurePathInEnv, + resolveCommandForLogs, runChildProcess, } from "../utils.js"; @@ -21,6 +23,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const id = req.params.id as string; + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, workspace.companyId); + const readiness = await svc.getCloseReadiness(id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + res.json(readiness); + }); + + router.get("/execution-workspaces/:id/workspace-operations", async (req, res) => { + const id = req.params.id as string; + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, workspace.companyId); + const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id); + res.json(operations); + }); + + router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service action not found" }); + return; + } + + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const workspaceCwd = existing.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" }); + return; + } + + const projectWorkspace = existing.projectWorkspaceId + ? await db + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + repoUrl: projectWorkspaces.repoUrl, + repoRef: projectWorkspaces.repoRef, + defaultRef: projectWorkspaces.defaultRef, + metadata: projectWorkspaces.metadata, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.id, existing.projectWorkspaceId), + eq(projectWorkspaces.companyId, existing.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( + (projectWorkspace?.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null; + const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; + + if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { + res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" }); + return; + } + + const actor = getActorInfo(req); + const recorder = workspaceOperationsSvc.createRecorder({ + companyId: existing.companyId, + executionWorkspaceId: existing.id, + }); + let runtimeServiceCount = existing.runtimeServices?.length ?? 0; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: existing.cwd, + metadata: { + action, + executionWorkspaceId: existing.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: existing.id, + workspaceCwd, + }); + } + + if (action === "start" || action === "restart") { + const startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + issue: existing.sourceIssueId + ? { + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } + : null, + workspace: { + baseCwd: workspaceCwd, + source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: existing.projectId, + workspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + repoRef: existing.baseRef, + strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: workspaceCwd, + branchName: existing.branchName, + worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: existing.id, + config: { workspaceRuntime: effectiveRuntimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { + desiredState: action === "stop" ? "stopped" : "running", + }); + await svc.update(existing.id, { metadata }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + system: + action === "stop" + ? "Stopped execution workspace runtime services.\n" + : action === "restart" + ? "Restarted execution workspace runtime services.\n" + : "Started execution workspace runtime services.\n", + metadata: { + runtimeServiceCount, + }, + }; + }, + }); + + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: `execution_workspace.runtime_${action}`, + entityType: "execution_workspace", + entityId: existing.id, + details: { + runtimeServiceCount, + }, + }); + + res.json({ + workspace, + operation, + }); + }); + router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -52,25 +249,43 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const patch: Record = { - ...req.body, - ...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}), + ...(req.body.name === undefined ? {} : { name: req.body.name }), + ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), + ...(req.body.repoUrl === undefined ? {} : { repoUrl: req.body.repoUrl }), + ...(req.body.baseRef === undefined ? {} : { baseRef: req.body.baseRef }), + ...(req.body.branchName === undefined ? {} : { branchName: req.body.branchName }), + ...(req.body.providerRef === undefined ? {} : { providerRef: req.body.providerRef }), + ...(req.body.status === undefined ? {} : { status: req.body.status }), + ...(req.body.cleanupReason === undefined ? {} : { cleanupReason: req.body.cleanupReason }), + ...(req.body.cleanupEligibleAt !== undefined + ? { cleanupEligibleAt: req.body.cleanupEligibleAt ? new Date(req.body.cleanupEligibleAt) : null } + : {}), }; + if (req.body.metadata !== undefined || req.body.config !== undefined) { + const requestedMetadata = req.body.metadata === undefined + ? (existing.metadata as Record | null) + : (req.body.metadata as Record | null); + patch.metadata = req.body.config === undefined + ? requestedMetadata + : mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null); + } let workspace = existing; let cleanupWarnings: string[] = []; + const configForCleanup = readExecutionWorkspaceConfig( + ((patch.metadata as Record | null | undefined) ?? (existing.metadata as Record | null)) ?? null, + ); if (req.body.status === "archived" && existing.status !== "archived") { - const linkedIssues = await db - .select({ - id: issues.id, - status: issues.status, - }) - .from(issues) - .where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id))); - const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status)); + const readiness = await svc.getCloseReadiness(existing.id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } - if (activeLinkedIssues.length > 0) { + if (readiness.state === "blocked") { res.status(409).json({ - error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`, + error: readiness.blockingReasons[0] ?? "Execution workspace cannot be closed right now", + closeReadiness: readiness, }); return; } @@ -88,6 +303,21 @@ export function executionWorkspaceRoutes(db: Db) { } workspace = archivedWorkspace; + if (existing.mode === "shared_workspace") { + await db + .update(issues) + .set({ + executionWorkspaceId: null, + updatedAt: new Date(), + }) + .where( + and( + eq(issues.companyId, existing.companyId), + eq(issues.executionWorkspaceId, existing.id), + ), + ); + } + try { await stopRuntimeServicesForExecutionWorkspace({ db, @@ -101,7 +331,7 @@ export function executionWorkspaceRoutes(db: Db) { cleanupCommand: projectWorkspaces.cleanupCommand, }) .from(projectWorkspaces) - .where( + .where( and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), @@ -121,7 +351,8 @@ export function executionWorkspaceRoutes(db: Db) { const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ workspace: existing, projectWorkspace, - teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + teardownCommand: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configForCleanup?.cleanupCommand ?? null, recorder: workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index edf86063..688547ae 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -303,6 +303,7 @@ export function issueRoutes(db: Db, storage: StorageService) { inboxArchivedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 51555ff5..b200b354 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,13 +8,15 @@ import { updateProjectWorkspaceSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { projectService, logActivity } from "../services/index.js"; +import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + const workspaceOperations = workspaceOperationService(db); async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; @@ -229,6 +231,145 @@ export function projectRoutes(db: Db) { }, ); + router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const workspaceId = req.params.workspaceId as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service action not found" }); + return; + } + + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + + const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null; + if (!workspace) { + res.status(404).json({ error: "Project workspace not found" }); + return; + } + + const workspaceCwd = workspace.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" }); + return; + } + + const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; + if ((action === "start" || action === "restart") && !runtimeConfig) { + res.status(422).json({ error: "Project workspace has no runtime service configuration" }); + return; + } + + const actor = getActorInfo(req); + const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); + let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: workspace.cwd, + metadata: { + action, + projectId: project.id, + projectWorkspaceId: workspace.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForProjectWorkspace({ + db, + projectWorkspaceId: workspace.id, + }); + } + + if (action === "start" || action === "restart") { + const startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: project.companyId, + }, + issue: null, + workspace: { + baseCwd: workspaceCwd, + source: "project_primary", + projectId: project.id, + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + strategy: "project_primary", + cwd: workspaceCwd, + branchName: workspace.defaultRef ?? workspace.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + await svc.updateWorkspace(project.id, workspace.id, { + runtimeConfig: { + desiredState: action === "stop" ? "stopped" : "running", + }, + }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + system: + action === "stop" + ? "Stopped project workspace runtime services.\n" + : action === "restart" + ? "Restarted project workspace runtime services.\n" + : "Started project workspace runtime services.\n", + metadata: { + runtimeServiceCount, + }, + }; + }, + }); + + const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace; + + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: `project.workspace_runtime_${action}`, + entityType: "project", + entityId: project.id, + details: { + projectWorkspaceId: workspace.id, + runtimeServiceCount, + }, + }); + + res.json({ + workspace: updatedWorkspace, + operation, + }); + }); + router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ea4dd163..a1d3b41d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,11 +1,292 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { executionWorkspaces } from "@paperclipai/db"; -import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import type { + ExecutionWorkspace, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceConfig, + WorkspaceRuntimeService, +} from "@paperclipai/shared"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; +const execFileAsync = promisify(execFile); +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); -function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function cloneRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return { ...value }; +} + +async function pathExists(value: string | null | undefined) { + if (!value) return false; + try { + await fs.access(value); + return true; + } catch { + return false; + } +} + +async function runGit(args: string[], cwd: string) { + return await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{ + git: ExecutionWorkspaceCloseGitReadiness | null; + warnings: string[]; +}> { + const warnings: string[] = []; + const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd); + const createdByRuntime = workspace.metadata?.createdByRuntime === true; + const expectsGitInspection = + workspace.providerType === "git_worktree" || + Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath); + + if (!expectsGitInspection) { + return { git: null, warnings }; + } + + if (!workspacePath) { + warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close."); + return { git: null, warnings }; + } + + if (!(await pathExists(workspacePath))) { + warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`); + return { + git: { + repoRoot: null, + workspacePath, + branchName: workspace.branchName, + baseRef: workspace.baseRef, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: false, + dirtyEntryCount: 0, + untrackedEntryCount: 0, + aheadCount: null, + behindCount: null, + isMergedIntoBase: null, + createdByRuntime, + }, + warnings, + }; + } + + let repoRoot: string | null = null; + try { + repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null; + } catch (error) { + warnings.push( + `Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let branchName = workspace.branchName; + if (repoRoot && !branchName) { + try { + branchName = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], workspacePath)).stdout.trim() || null; + } catch { + branchName = workspace.branchName; + } + } + + let dirtyEntryCount = 0; + let untrackedEntryCount = 0; + if (repoRoot) { + try { + const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout; + for (const line of statusOutput.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith("??")) { + untrackedEntryCount += 1; + continue; + } + dirtyEntryCount += 1; + } + } catch (error) { + warnings.push( + `Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + let aheadCount: number | null = null; + let behindCount: number | null = null; + let isMergedIntoBase: boolean | null = null; + const baseRef = workspace.baseRef; + + if (repoRoot && baseRef) { + try { + const counts = (await runGit(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], workspacePath)).stdout.trim(); + const [behindRaw, aheadRaw] = counts.split(/\s+/); + behindCount = behindRaw ? Number.parseInt(behindRaw, 10) : 0; + aheadCount = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0; + } catch (error) { + warnings.push( + `Could not compare this workspace against ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + await runGit(["merge-base", "--is-ancestor", "HEAD", baseRef], workspacePath); + isMergedIntoBase = true; + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : null; + if (code === 1) isMergedIntoBase = false; + else { + warnings.push( + `Could not determine whether this workspace is merged into ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + return { + git: { + repoRoot, + workspacePath, + branchName, + baseRef, + hasDirtyTrackedFiles: dirtyEntryCount > 0, + hasUntrackedFiles: untrackedEntryCount > 0, + dirtyEntryCount, + untrackedEntryCount, + aheadCount, + behindCount, + isMergedIntoBase, + createdByRuntime, + }, + warnings, + }; +} + +export function readExecutionWorkspaceConfig(metadata: Record | null | undefined): ExecutionWorkspaceConfig | null { + const raw = isRecord(metadata?.config) ? metadata.config : null; + if (!raw) return null; + + const config: ExecutionWorkspaceConfig = { + provisionCommand: readNullableString(raw.provisionCommand), + teardownCommand: readNullableString(raw.teardownCommand), + cleanupCommand: readNullableString(raw.cleanupCommand), + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, + }; + + const hasConfig = Object.values(config).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + return hasConfig ? config : null; +} + +export function mergeExecutionWorkspaceConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readExecutionWorkspaceConfig(metadata) ?? { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + desiredState: null, + }; + + if (patch === null) { + delete nextMetadata.config; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ExecutionWorkspaceConfig = { + provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, + teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, + cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, + workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined + ? patch.desiredState === "running" || patch.desiredState === "stopped" + ? patch.desiredState + : null + : current.desiredState, + }; + + const hasConfig = Object.values(nextConfig).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + if (hasConfig) { + nextMetadata.config = { + provisionCommand: nextConfig.provisionCommand, + teardownCommand: nextConfig.teardownCommand, + cleanupCommand: nextConfig.cleanupCommand, + workspaceRuntime: nextConfig.workspaceRuntime, + desiredState: nextConfig.desiredState, + }; + } else { + delete nextMetadata.config; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} + +function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService { + return { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"], + scopeId: row.scopeId ?? null, + serviceName: row.serviceName, + status: row.status as WorkspaceRuntimeService["status"], + lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"], + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: row.port ?? null, + url: row.url ?? null, + provider: row.provider as WorkspaceRuntimeService["provider"], + providerRef: row.providerRef ?? null, + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: row.lastUsedAt, + startedAt: row.startedAt, + stoppedAt: row.stoppedAt ?? null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toExecutionWorkspace( + row: ExecutionWorkspaceRow, + runtimeServices: WorkspaceRuntimeService[] = [], +): ExecutionWorkspace { return { id: row.id, companyId: row.companyId, @@ -28,7 +309,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { closedAt: row.closedAt ?? null, cleanupEligibleAt: row.cleanupEligibleAt ?? null, cleanupReason: row.cleanupReason ?? null, + config: readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null), metadata: (row.metadata as Record | null) ?? null, + runtimeServices, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -63,7 +346,7 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map(toExecutionWorkspace); + return rows.map((row) => toExecutionWorkspace(row)); }, getById: async (id: string) => { @@ -72,7 +355,268 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); - return row ? toExecutionWorkspace(row) : null; + if (!row) return null; + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); + }, + + getCloseReadiness: async (id: string): Promise => { + const workspace = await db + .select() + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, id)) + .then((rows) => rows[0] ?? null); + if (!workspace) return null; + + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + const runtimeServices = runtimeServiceRows.map(toRuntimeService); + + const linkedIssues = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, workspace.companyId), eq(issues.executionWorkspaceId, workspace.id))); + + const projectWorkspace = workspace.projectWorkspaceId + ? await db + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + isPrimary: projectWorkspaces.isPrimary, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.id, workspace.projectWorkspaceId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const primaryProjectWorkspace = workspace.projectId + ? await db + .select({ + id: projectWorkspaces.id, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + eq(projectWorkspaces.isPrimary, true), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const projectPolicy = workspace.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; + + const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices); + const config = readExecutionWorkspaceConfig((workspace.metadata as Record | null) ?? null); + const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace); + const warnings = [...gitWarnings]; + const blockingReasons: string[] = []; + const isSharedWorkspace = executionWorkspace.mode === "shared_workspace"; + const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd); + const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null; + const resolvedPrimaryWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; + const isProjectPrimaryWorkspace = + workspace.projectWorkspaceId != null + && workspace.projectWorkspaceId === primaryProjectWorkspace?.id + && resolvedWorkspacePath != null + && resolvedPrimaryWorkspacePath != null + && resolvedWorkspacePath === resolvedPrimaryWorkspacePath; + + const linkedIssueSummaries = linkedIssues.map((issue) => ({ + ...issue, + isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status), + })); + + const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal); + if (blockingIssues.length > 0) { + const linkedIssueMessage = + blockingIssues.length === 1 + ? "This workspace is still linked to an open issue." + : `This workspace is still linked to ${blockingIssues.length} open issues.`; + if (isSharedWorkspace) { + warnings.push(`${linkedIssueMessage} Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.`); + } else { + blockingReasons.push(linkedIssueMessage); + } + } + + if (isSharedWorkspace) { + warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record."); + } + + if (runtimeServices.some((service) => service.status !== "stopped")) { + warnings.push( + runtimeServices.length === 1 + ? "Closing this workspace will stop 1 attached runtime service." + : `Closing this workspace will stop ${runtimeServices.length} attached runtime services.`, + ); + } + + if (git?.hasDirtyTrackedFiles) { + warnings.push( + git.dirtyEntryCount === 1 + ? "The workspace has 1 modified tracked file." + : `The workspace has ${git.dirtyEntryCount} modified tracked files.`, + ); + } + if (git?.hasUntrackedFiles) { + warnings.push( + git.untrackedEntryCount === 1 + ? "The workspace has 1 untracked file." + : `The workspace has ${git.untrackedEntryCount} untracked files.`, + ); + } + if (git?.aheadCount && git.aheadCount > 0 && git.isMergedIntoBase === false) { + warnings.push( + git.aheadCount === 1 + ? `This workspace is 1 commit ahead of ${git.baseRef ?? "the base ref"} and is not merged.` + : `This workspace is ${git.aheadCount} commits ahead of ${git.baseRef ?? "the base ref"} and is not merged.`, + ); + } + if (git?.behindCount && git.behindCount > 0) { + warnings.push( + git.behindCount === 1 + ? `This workspace is 1 commit behind ${git.baseRef ?? "the base ref"}.` + : `This workspace is ${git.behindCount} commits behind ${git.baseRef ?? "the base ref"}.`, + ); + } + + const plannedActions: ExecutionWorkspaceCloseAction[] = [ + { + kind: "archive_record", + label: "Archive workspace record", + description: "Keep the execution workspace history and issue linkage, but remove it from active workspace lists.", + command: null, + }, + ]; + + if (runtimeServices.some((service) => service.status !== "stopped")) { + plannedActions.push({ + kind: "stop_runtime_services", + label: runtimeServices.length === 1 ? "Stop attached runtime service" : "Stop attached runtime services", + description: + runtimeServices.length === 1 + ? `${runtimeServices[0]?.serviceName ?? "A runtime service"} will be stopped before cleanup.` + : `${runtimeServices.length} runtime services will be stopped before cleanup.`, + command: null, + }); + } + + const configuredCleanupCommands = [ + { + kind: "cleanup_command" as const, + label: "Run workspace cleanup command", + description: "Workspace-specific cleanup runs before teardown.", + command: config?.cleanupCommand ?? null, + }, + { + kind: "cleanup_command" as const, + label: "Run project workspace cleanup command", + description: "Project workspace cleanup runs before execution workspace teardown.", + command: projectWorkspace?.cleanupCommand ?? null, + }, + ]; + for (const action of configuredCleanupCommands) { + if (!action.command) continue; + plannedActions.push(action); + } + + const teardownCommand = config?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null; + if (teardownCommand) { + plannedActions.push({ + kind: "teardown_command", + label: "Run teardown command", + description: "Teardown runs after cleanup commands during workspace close.", + command: teardownCommand, + }); + } + + if (executionWorkspace.providerType === "git_worktree" && workspacePath) { + plannedActions.push({ + kind: "git_worktree_remove", + label: "Remove git worktree", + description: `Paperclip will run git worktree cleanup for ${workspacePath}.`, + command: `git worktree remove --force ${workspacePath}`, + }); + } + + if (git?.createdByRuntime && executionWorkspace.branchName) { + plannedActions.push({ + kind: "git_branch_delete", + label: "Delete runtime-created branch", + description: "Paperclip will try to delete the runtime-created branch after removing the worktree.", + command: `git branch -d ${executionWorkspace.branchName}`, + }); + } + + if (executionWorkspace.providerType === "local_fs" && git?.createdByRuntime && workspacePath) { + const resolvedWorkspacePath = path.resolve(workspacePath); + const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; + const containsProjectWorkspace = resolvedProjectWorkspacePath + ? ( + resolvedWorkspacePath === resolvedProjectWorkspacePath || + resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) + : false; + if (containsProjectWorkspace) { + warnings.push(`Paperclip will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`); + } else { + plannedActions.push({ + kind: "remove_local_directory", + label: "Remove runtime-created directory", + description: `Paperclip will remove the runtime-created directory at ${workspacePath}.`, + command: `rm -rf ${workspacePath}`, + }); + } + } + + const state = + blockingReasons.length > 0 + ? "blocked" + : warnings.length > 0 + ? "ready_with_warnings" + : "ready"; + + return { + workspaceId: workspace.id, + state, + blockingReasons, + warnings, + linkedIssues: linkedIssueSummaries, + plannedActions, + isDestructiveCloseAllowed: blockingReasons.length === 0, + isSharedWorkspace, + isProjectPrimaryWorkspace, + git, + runtimeServices, + }; }, create: async (data: typeof executionWorkspaces.$inferInsert) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c909b9b7..bc66f399 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType } from "@paperclipai/shared"; +import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -40,7 +40,7 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; -import { executionWorkspaceService } from "./execution-workspaces.js"; +import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, @@ -76,6 +76,61 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "pi_local", ]); +export function applyPersistedExecutionWorkspaceConfig(input: { + config: Record; + workspaceConfig: ExecutionWorkspaceConfig | null; + mode: ReturnType; +}) { + const nextConfig = { ...input.config }; + + if (input.mode !== "agent_default") { + if (input.workspaceConfig?.workspaceRuntime === null) { + delete nextConfig.workspaceRuntime; + } else if (input.workspaceConfig?.workspaceRuntime) { + nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; + } + } + + if (input.workspaceConfig && input.mode === "isolated_workspace") { + const nextStrategy = parseObject(nextConfig.workspaceStrategy); + if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; + else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; + if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; + else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; + nextConfig.workspaceStrategy = nextStrategy; + } + + return nextConfig; +} + +export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record) { + const nextConfig = { ...config }; + delete nextConfig.workspaceRuntime; + return nextConfig; +} + +function buildExecutionWorkspaceConfigSnapshot(config: Record): Partial | null { + const strategy = parseObject(config.workspaceStrategy); + const snapshot: Partial = {}; + + if ("workspaceStrategy" in config) { + snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; + snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; + } + + if ("workspaceRuntime" in config) { + const workspaceRuntime = parseObject(config.workspaceRuntime); + snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; + } + + const hasSnapshot = Object.values(snapshot).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + return hasSnapshot ? snapshot : null; +} + function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { const trimmed = repoUrl?.trim() ?? ""; if (!trimmed) return null; @@ -2048,18 +2103,6 @@ export function heartbeatService(db: Db) { mode: executionWorkspaceMode, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : workspaceManagedConfig; - const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - mergedConfig, - ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); - const runtimeConfig = { - ...resolvedConfig, - paperclipRuntimeSkills: runtimeSkillEntries, - }; const issueRef = issueContext ? { id: issueContext.id, @@ -2073,6 +2116,25 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ + config: workspaceManagedConfig, + workspaceConfig: existingExecutionWorkspace?.config ?? null, + mode: executionWorkspaceMode, + }); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } + : persistedWorkspaceManagedConfig; + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); + const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + executionRunConfig, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, @@ -2103,6 +2165,14 @@ export function heartbeatService(db: Db) { existingExecutionWorkspace && existingExecutionWorkspace.status !== "archived"; let persistedExecutionWorkspace = null; + const nextExecutionWorkspaceMetadataBase = { + ...(existingExecutionWorkspace?.metadata ?? {}), + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + } as Record; + const nextExecutionWorkspaceMetadata = configSnapshot + ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) + : nextExecutionWorkspaceMetadataBase; try { persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { @@ -2114,11 +2184,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, status: "active", lastUsedAt: new Date(), - metadata: { - ...(existingExecutionWorkspace.metadata ?? {}), - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : resolvedProjectId ? await executionWorkspacesSvc.create({ @@ -2145,10 +2211,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, lastUsedAt: new Date(), openedAt: new Date(), - metadata: { - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : null; } catch (error) { @@ -2175,7 +2238,8 @@ export function heartbeatService(db: Db) { cwd: resolvedWorkspace.cwd, cleanupCommand: null, }, - teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configSnapshot?.cleanupCommand ?? null, + teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, recorder: workspaceOperationRecorder, }); } catch (cleanupError) { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index fccd6c7f..241355b6 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -28,5 +28,5 @@ export { workProductService } from "./work-products.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; -export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js"; +export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 70652535..0e1defe3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -70,6 +70,7 @@ export interface IssueFilters { inboxArchivedByUserId?: string; unreadForUserId?: string; projectId?: string; + executionWorkspaceId?: string; parentId?: string; labelId?: string; originKind?: string; @@ -647,6 +648,9 @@ export function issueService(db: Db) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.executionWorkspaceId) { + conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); + } if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts new file mode 100644 index 00000000..68dbbdc8 --- /dev/null +++ b/server/src/services/local-service-supervisor.ts @@ -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 | null; +} + +export interface LocalServiceIdentityInput { + profileKind: string; + serviceName: string; + cwd: string; + command: string; + envFingerprint: string; + port: number | null; + scope: Record | 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; + 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; + 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) + : 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; +}) { + 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>, +) { + 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, + 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; + } +} diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts new file mode 100644 index 00000000..8252fecd --- /dev/null +++ b/server/src/services/project-workspace-runtime-config.ts @@ -0,0 +1,59 @@ +import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cloneRecord(value: unknown): Record | null { + return isRecord(value) ? { ...value } : null; +} + +function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] { + return value === "running" || value === "stopped" ? value : null; +} + +export function readProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, +): ProjectWorkspaceRuntimeConfig | null { + const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null; + if (!raw) return null; + + const config: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: readDesiredState(raw.desiredState), + }; + + const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null; + return hasConfig ? config : null; +} + +export function mergeProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readProjectWorkspaceRuntimeConfig(metadata) ?? { + workspaceRuntime: null, + desiredState: null, + }; + + if (patch === null) { + delete nextMetadata.runtimeConfig; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: + patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState, + }; + + if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) { + delete nextMetadata.runtimeConfig; + } else { + nextMetadata.runtimeConfig = nextConfig; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 4f7d1eb2..7c7c4bb0 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -9,11 +9,13 @@ import { type ProjectCodebase, type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, + type ProjectWorkspaceRuntimeConfig, type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; type ProjectRow = typeof projects.$inferSelect; @@ -34,6 +36,7 @@ type CreateWorkspaceInput = { remoteWorkspaceRef?: string | null; sharedWorkspaceKey?: string | null; metadata?: Record | null; + runtimeConfig?: Partial | null; isPrimary?: boolean; }; type UpdateWorkspaceInput = Partial; @@ -149,6 +152,7 @@ function toWorkspace( remoteWorkspaceRef: row.remoteWorkspaceRef ?? null, sharedWorkspaceKey: row.sharedWorkspaceKey ?? null, metadata: (row.metadata as Record | null) ?? null, + runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null), isPrimary: row.isPrimary, runtimeServices, createdAt: row.createdAt, @@ -611,7 +615,13 @@ export function projectService(db: Db) { remoteProvider: readNonEmptyString(data.remoteProvider), remoteWorkspaceRef, sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey), - metadata: (data.metadata as Record | null | undefined) ?? null, + metadata: + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + (data.metadata as Record | null | undefined) ?? null, + data.runtimeConfig ?? null, + ) + : (data.metadata as Record | null | undefined) ?? null, isPrimary: shouldBePrimary, }) .returning() @@ -681,7 +691,17 @@ export function projectService(db: Db) { if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider); if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef; if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey); - if (data.metadata !== undefined) patch.metadata = data.metadata; + if (data.metadata !== undefined || data.runtimeConfig !== undefined) { + patch.metadata = + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + data.metadata !== undefined + ? (data.metadata as Record | null | undefined) + : ((existing.metadata as Record | null | undefined) ?? null), + data.runtimeConfig ?? null, + ) + : data.metadata; + } const updated = await db.transaction(async (tx) => { if (data.isPrimary === true) { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 7cb780ce..dfece576 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -6,11 +6,23 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; -import { workspaceRuntimeServices } from "@paperclipai/db"; +import { executionWorkspaces, projectWorkspaces, 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"; +import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; +import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; export interface ExecutionWorkspaceInput { baseCwd: string; @@ -28,7 +40,7 @@ export interface ExecutionWorkspaceIssueRef { } export interface ExecutionWorkspaceAgentRef { - id: string; + id: string | null; name: string; companyId: string; } @@ -77,12 +89,24 @@ interface RuntimeServiceRecord extends RuntimeServiceRef { leaseRunIds: Set; idleTimer: ReturnType | null; envFingerprint: string; + serviceKey: string; + profileKind: string; + processGroupId: number | null; } const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); +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(",")}]`; @@ -102,6 +126,8 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ } } delete env.DATABASE_URL; + delete env.npm_config_tailscale_auth; + delete env.npm_config_authenticated_private; return env; } @@ -189,7 +215,7 @@ function renderWorkspaceTemplate(template: string, input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, project: { @@ -312,7 +338,7 @@ function buildWorkspaceCommandEnv(input: { env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false"; env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? ""; env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? ""; - env.PAPERCLIP_AGENT_ID = input.agent.id; + env.PAPERCLIP_AGENT_ID = input.agent.id ?? ""; env.PAPERCLIP_AGENT_NAME = input.agent.name; env.PAPERCLIP_COMPANY_ID = input.agent.companyId; env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? ""; @@ -702,6 +728,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { cwd: string | null; cleanupCommand: string | null; } | null; + cleanupCommand?: string | null; teardownCommand?: string | null; recorder?: WorkspaceOperationRecorder | null; }) { @@ -713,6 +740,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { }); const createdByRuntime = input.workspace.metadata?.createdByRuntime === true; const cleanupCommands = [ + input.cleanupCommand ?? null, input.projectWorkspace?.cleanupCommand ?? null, input.teardownCommand ?? null, ] @@ -879,13 +907,95 @@ function buildTemplateData(input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, port: input.port ?? "", }; } +function renderRuntimeServiceEnv(input: { + envConfig: Record; + templateData: ReturnType; +}) { + const rendered: Record = {}; + for (const [key, value] of Object.entries(input.envConfig)) { + if (typeof value !== "string") continue; + rendered[key] = renderTemplate(value, input.templateData); + } + return rendered; +} + +function resolveRuntimeServiceReuseIdentity(input: { + service: Record; + workspace: RealizedExecutionWorkspace; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + adapterEnv: Record; + scopeType: RuntimeServiceRef["scopeType"]; + scopeId: string | null; +}): { + serviceName: string; + lifecycle: RuntimeServiceRef["lifecycle"]; + command: string; + serviceCwd: string; + envConfig: Record; + envFingerprint: string; + explicitPort: number; + identityPort: number | null; + reuseKey: string | null; +} { + const serviceName = asString(input.service.name, "service"); + const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; + const command = asString(input.service.command, ""); + const serviceCwdTemplate = asString(input.service.cwd, "."); + const portConfig = parseObject(input.service.port); + const envConfig = parseObject(input.service.env); + const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0)); + const identityPort = explicitPort > 0 ? explicitPort : null; + const templateData = buildTemplateData({ + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + port: identityPort, + }); + const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); + const renderedEnv = renderRuntimeServiceEnv({ + envConfig, + templateData, + }); + const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex"); + const reuseKey = + lifecycle === "shared" + ? createHash("sha256") + .update( + stableStringify({ + scopeType: input.scopeType, + scopeId: input.scopeId, + serviceName, + command, + cwd: serviceCwd, + port: identityPort, + env: renderedEnv, + }), + ) + .digest("hex") + : null; + + return { + serviceName, + lifecycle, + command, + serviceCwd, + envConfig, + envFingerprint, + explicitPort, + identityPort, + reuseKey, + }; +} + function resolveServiceScopeId(input: { service: Record; workspace: RealizedExecutionWorkspace; @@ -1067,7 +1177,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { url: report.url ?? null, provider: "adapter_managed", providerRef: report.providerRef ?? null, - ownerAgentId: report.ownerAgentId ?? input.agent.id, + ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null, startedByRunId: input.runId, lastUsedAt: nowIso, startedAt: nowIso, @@ -1082,6 +1192,8 @@ export function normalizeAdapterManagedRuntimeServices(input: { async function startLocalRuntimeService(input: { db?: Db; runId: string; + leaseRunId?: string | null; + startedByRunId?: string | null; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; @@ -1093,14 +1205,33 @@ async function startLocalRuntimeService(input: { scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; }): Promise { - const serviceName = asString(input.service.name, "service"); - const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; - const command = asString(input.service.command, ""); + const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId; + const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId; + const identity = resolveRuntimeServiceReuseIdentity({ + service: input.service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType: input.scopeType, + scopeId: input.scopeId, + }); + const serviceName = identity.serviceName; + const lifecycle = identity.lifecycle; + const command = identity.command; 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 envConfig = identity.envConfig; + const envFingerprint = identity.envFingerprint; + const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint; + const explicitPort = identity.explicitPort; + const identityPort = identity.identityPort; + const port = + asString(portConfig.type, "") === "auto" + ? await allocatePort() + : explicitPort > 0 + ? explicitPort + : null; const templateData = buildTemplateData({ workspace: input.workspace, agent: input.agent, @@ -1108,20 +1239,95 @@ async function startLocalRuntimeService(input: { adapterEnv: input.adapterEnv, port, }); - const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); + const serviceCwd = + port === identityPort + ? identity.serviceCwd + : resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd); const env: Record = { ...sanitizeRuntimeServiceBaseEnv(process.env), ...input.adapterEnv, } as Record; - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") { - env[key] = renderTemplate(value, templateData); - } + for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { + env[key] = value; } if (port) { 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 ?? null, + startedByRunId, + lastUsedAt: new Date().toISOString(), + startedAt: adoptedRecord.startedAt, + stoppedAt: null, + stopPolicy, + healthStatus: "healthy", + reused: true, + db: input.db, + child: null, + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), + 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, @@ -1142,13 +1348,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) { @@ -1158,8 +1357,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, @@ -1178,20 +1376,54 @@ async function startLocalRuntimeService(input: { url, provider: "local_process", providerRef: child.pid ? String(child.pid) : null, - ownerAgentId: input.agent.id, - startedByRunId: input.runId, + ownerAgentId: input.agent.id ?? null, + startedByRunId, lastUsedAt: new Date().toISOString(), startedAt: new Date().toISOString(), stoppedAt: null, - stopPolicy: parseObject(input.service.stopPolicy), + stopPolicy, healthStatus: "healthy", reused: false, db: input.db, child, - leaseRunIds: new Set([input.runId]), + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), 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) { @@ -1209,15 +1441,28 @@ async function stopRuntimeService(serviceId: string) { if (!record) return; clearIdleTimer(record); record.status = "stopped"; + record.healthStatus = "unknown"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); - if (record.child && record.child.pid) { - terminateChildProcess(record.child); - } runtimeServicesById.delete(serviceId); - if (record.reuseKey) { + if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { runtimeServicesByReuseKey.delete(record.reuseKey); } + if (record.child && record.child.pid) { + await terminateLocalService({ + pid: record.child.pid, + processGroupId: record.processGroupId ?? record.child.pid, + }); + } else if (record.providerRef) { + const pid = Number.parseInt(record.providerRef, 10); + if (Number.isInteger(pid) && pid > 0) { + await terminateLocalService({ + pid, + processGroupId: record.processGroupId, + }); + } + } + await removeLocalServiceRegistryRecord(record.serviceKey); await persistRuntimeServiceRecord(record.db, record); } @@ -1262,10 +1507,18 @@ 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); }); } +function readRuntimeServiceEntries(config: Record) { + const runtime = parseObject(config.workspaceRuntime); + return Array.isArray(runtime.services) + ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) + : []; +} + export async function ensureRuntimeServicesForRun(input: { db?: Db; runId: string; @@ -1277,17 +1530,13 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { - const runtime = parseObject(input.config.workspaceRuntime); - const rawServices = Array.isArray(runtime.services) - ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) - : []; + const rawServices = readRuntimeServiceEntries(input.config); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); try { for (const service of rawServices) { - const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; const { scopeType, scopeId } = resolveServiceScopeId({ service, workspace: input.workspace, @@ -1296,13 +1545,15 @@ export async function ensureRuntimeServicesForRun(input: { runId: input.runId, agent: input.agent, }); - const envConfig = parseObject(service.env); - const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); - const serviceName = asString(service.name, "service"); - const reuseKey = - lifecycle === "shared" - ? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") - : null; + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; if (reuseKey) { const existingId = runtimeServicesByReuseKey.get(reuseKey); @@ -1312,6 +1563,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 })); @@ -1346,6 +1601,83 @@ export async function ensureRuntimeServicesForRun(input: { return refs; } +export async function startRuntimeServicesForWorkspaceControl(input: { + db?: Db; + invocationId?: string; + actor: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; + config: Record; + adapterEnv: Record; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + const rawServices = readRuntimeServiceEntries(input.config); + const refs: RuntimeServiceRef[] = []; + const invocationId = input.invocationId ?? randomUUID(); + + for (const service of rawServices) { + const { scopeType, scopeId } = resolveServiceScopeId({ + service, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: invocationId, + agent: input.actor, + }); + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.actor, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; + + if (reuseKey) { + const existingId = runtimeServicesByReuseKey.get(reuseKey); + const existing = existingId ? runtimeServicesById.get(existingId) : null; + if (existing && existing.status === "running") { + 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); + refs.push(toRuntimeServiceRef(existing, { reused: true })); + continue; + } + } + + // Manually controlled services are not tied to a heartbeat run lifecycle, so they do not + // retain a run lease and never persist a startedByRunId foreign key. + const record = await startLocalRuntimeService({ + db: input.db, + runId: invocationId, + leaseRunId: null, + startedByRunId: null, + agent: input.actor, + issue: input.issue, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + adapterEnv: input.adapterEnv, + service, + onLog: input.onLog, + reuseKey, + scopeType, + scopeId, + }); + registerRuntimeService(input.db, record); + await persistRuntimeServiceRecord(input.db, record); + refs.push(toRuntimeServiceRef(record)); + } + + return refs; +} + export async function releaseRuntimeServicesForRun(runId: string) { const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; runtimeServiceLeasesByRun.delete(runId); @@ -1396,6 +1728,39 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { } } +export async function stopRuntimeServicesForProjectWorkspace(input: { + db?: Db; + projectWorkspaceId: string; +}) { + const matchingServiceIds = Array.from(runtimeServicesById.values()) + .filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace") + .map((record) => record.id); + + for (const serviceId of matchingServiceIds) { + await stopRuntimeService(serviceId); + } + + if (input.db) { + const now = new Date(); + await input.db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), + ); + } +} + export async function listWorkspaceRuntimeServicesForProjectWorkspaces( db: Db, companyId: string, @@ -1409,6 +1774,7 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces( and( eq(workspaceRuntimeServices.companyId, companyId), inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), ), ) .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); @@ -1424,8 +1790,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( @@ -1434,26 +1800,171 @@ 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 | 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 restartDesiredRuntimeServicesOnStartup(db: Db) { + let restarted = 0; + let failed = 0; + + const projectWorkspaceRows = await db + .select() + .from(projectWorkspaces); + + for (const row of projectWorkspaceRows) { + const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null); + if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: null, + workspace: { + baseCwd: row.cwd, + source: "project_primary", + projectId: row.projectId, + workspaceId: row.id, + repoUrl: row.repoUrl ?? null, + repoRef: row.repoRef ?? null, + strategy: "project_primary", + cwd: row.cwd, + branchName: row.defaultRef ?? row.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + const executionWorkspaceRows = await db + .select() + .from(executionWorkspaces) + .where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"])); + + for (const row of executionWorkspaceRows) { + const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); + if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: row.sourceIssueId + ? { + id: row.sourceIssueId, + identifier: null, + title: row.name, + } + : null, + workspace: { + baseCwd: row.cwd, + source: row.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: row.projectId, + workspaceId: row.projectWorkspaceId ?? null, + repoUrl: row.repoUrl ?? null, + repoRef: row.baseRef ?? null, + strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: row.cwd, + branchName: row.branchName ?? null, + worktreePath: row.strategyType === "git_worktree" ? row.cwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: row.id, + config: { workspaceRuntime: config.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + return { restarted, failed }; } export async function persistAdapterManagedRuntimeServices(input: { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 38b5f4bc..f240defc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; +import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; @@ -144,6 +145,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -337,7 +340,10 @@ export function App() { } /> } /> } /> + } /> + } /> } /> + } /> } /> }> {boardRoutes()} diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index bf83999c..3644af77 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,4 +1,4 @@ -import type { ExecutionWorkspace } from "@paperclipai/shared"; +import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; export const executionWorkspacesApi = { @@ -22,5 +22,14 @@ export const executionWorkspacesApi = { return api.get(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`); }, get: (id: string) => api.get(`/execution-workspaces/${id}`), + getCloseReadiness: (id: string) => + api.get(`/execution-workspaces/${id}/close-readiness`), + listWorkspaceOperations: (id: string) => + api.get(`/execution-workspaces/${id}/workspace-operations`), + controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-services/${action}`, + {}, + ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 33ba63be..8d4834c3 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -28,6 +28,7 @@ export const issuesApi = { inboxArchivedByUserId?: string; unreadForUserId?: string; labelId?: string; + executionWorkspaceId?: string; originKind?: string; originId?: string; includeRoutineExecutions?: boolean; @@ -44,6 +45,7 @@ export const issuesApi = { if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.labelId) params.set("labelId", filters.labelId); + if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); if (filters?.originId) params.set("originId", filters.originId); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index c7177ac6..763718ff 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,4 +1,4 @@ -import type { Project, ProjectWorkspace } from "@paperclipai/shared"; +import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; function withCompanyScope(path: string, companyId?: string) { @@ -27,6 +27,16 @@ export const projectsApi = { projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), data, ), + controlWorkspaceRuntimeServices: ( + projectId: string, + workspaceId: string, + action: "start" | "stop" | "restart", + companyId?: string, + ) => + api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), + {}, + ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), remove: (id: string, companyId?: string) => api.delete(projectPath(id, companyId)), diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx new file mode 100644 index 00000000..f0547684 --- /dev/null +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -0,0 +1,314 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { Loader2 } from "lucide-react"; +import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { useToast } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { formatDateTime, issueUrl } from "../lib/utils"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +type ExecutionWorkspaceCloseDialogProps = { + workspaceId: string; + workspaceName: string; + currentStatus: ExecutionWorkspace["status"]; + open: boolean; + onOpenChange: (open: boolean) => void; + onClosed?: (workspace: ExecutionWorkspace) => void; +}; + +function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") { + if (state === "blocked") { + return "border-destructive/30 bg-destructive/5 text-destructive"; + } + if (state === "ready_with_warnings") { + return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300"; + } + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; +} + +export function ExecutionWorkspaceCloseDialog({ + workspaceId, + workspaceName, + currentStatus, + open, + onOpenChange, + onClosed, +}: ExecutionWorkspaceCloseDialogProps) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace"; + + const readinessQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId), + queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId), + enabled: open, + }); + + const closeWorkspace = useMutation({ + mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }), + onSuccess: (workspace) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) }); + pushToast({ + title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed", + tone: "success", + }); + onOpenChange(false); + onClosed?.(workspace); + }, + onError: (error) => { + pushToast({ + title: "Failed to close workspace", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + const readiness = readinessQuery.data ?? null; + const blockingIssues = readiness?.linkedIssues.filter((issue) => !issue.isTerminal) ?? []; + const otherLinkedIssues = readiness?.linkedIssues.filter((issue) => issue.isTerminal) ?? []; + const confirmDisabled = + currentStatus === "archived" || + closeWorkspace.isPending || + readinessQuery.isLoading || + readiness == null || + readiness.state === "blocked"; + + return ( + { + if (!closeWorkspace.isPending) onOpenChange(nextOpen); + }}> + + + {actionLabel} + + Archive {workspaceName} and clean up any owned workspace + artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. + + + + {readinessQuery.isLoading ? ( +
+ + Checking whether this workspace is safe to close... +
+ ) : readinessQuery.error ? ( +
+ {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} +
+ ) : readiness ? ( +
+
+
+ {readiness.state === "blocked" + ? "Close is blocked" + : readiness.state === "ready_with_warnings" + ? "Close is allowed with warnings" + : "Close is ready"} +
+
+ {readiness.isSharedWorkspace + ? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace." + : readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot + ? "This execution workspace has its own checkout path and can be archived independently." + : readiness.isProjectPrimaryWorkspace + ? "This execution workspace currently points at the project's primary workspace path." + : "This workspace is disposable and can be archived."} +
+
+ + {blockingIssues.length > 0 ? ( +
+

Blocking issues

+
+ {blockingIssues.map((issue) => ( +
+
+ + {issue.identifier ?? issue.id} · {issue.title} + + {issue.status} +
+
+ ))} +
+
+ ) : null} + + {readiness.blockingReasons.length > 0 ? ( +
+

Blocking reasons

+
    + {readiness.blockingReasons.map((reason, idx) => ( +
  • + {reason} +
  • + ))} +
+
+ ) : null} + + {readiness.warnings.length > 0 ? ( +
+

Warnings

+
    + {readiness.warnings.map((warning, idx) => ( +
  • + {warning} +
  • + ))} +
+
+ ) : null} + + {readiness.git ? ( +
+

Git status

+
+
+
+
Branch
+
{readiness.git.branchName ?? "Unknown"}
+
+
+
Base ref
+
{readiness.git.baseRef ?? "Not set"}
+
+
+
Merged into base
+
{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}
+
+
+
Ahead / behind
+
+ {(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()} +
+
+
+
Dirty tracked files
+
{readiness.git.dirtyEntryCount}
+
+
+
Untracked files
+
{readiness.git.untrackedEntryCount}
+
+
+
+
+ ) : null} + + {otherLinkedIssues.length > 0 ? ( +
+

Other linked issues

+
+ {otherLinkedIssues.map((issue) => ( +
+
+ + {issue.identifier ?? issue.id} · {issue.title} + + {issue.status} +
+
+ ))} +
+
+ ) : null} + + {readiness.runtimeServices.length > 0 ? ( +
+

Attached runtime services

+
+ {readiness.runtimeServices.map((service) => ( +
+
+ {service.serviceName} + {service.status} · {service.lifecycle} +
+
+ {service.url ?? service.command ?? service.cwd ?? "No additional details"} +
+
+ ))} +
+
+ ) : null} + +
+

Cleanup actions

+
+ {readiness.plannedActions.map((action, index) => ( +
+
{action.label}
+
{action.description}
+ {action.command ? ( +
+                        {action.command}
+                      
+ ) : null} +
+ ))} +
+
+ + {currentStatus === "cleanup_failed" ? ( +
+ Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the + workspace status if it succeeds. +
+ ) : null} + + {currentStatus === "archived" ? ( +
+ This workspace is already archived. +
+ ) : null} + + {readiness.git?.repoRoot ? ( +
+ Repo root: {readiness.git.repoRoot} + {readiness.git.workspacePath ? ( + <> + {" · "}Workspace path: {readiness.git.workspacePath} + + ) : null} +
+ ) : null} + +
+ Last checked {formatDateTime(new Date())} +
+
+ ) : null} + + + + + +
+
+ ); +} diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index 56484cab..73ecf9eb 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -6,7 +6,7 @@ import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; -import { cn } from "../lib/utils"; +import { cn, projectWorkspaceUrl } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react"; @@ -114,6 +114,29 @@ function configuredWorkspaceLabel( } } +function projectWorkspaceDetailLink(input: { + projectId: string | null | undefined; + projectWorkspaceId: string | null | undefined; +}) { + if (!input.projectId || !input.projectWorkspaceId) return null; + return projectWorkspaceUrl({ id: input.projectId, urlKey: input.projectId }, input.projectWorkspaceId); +} + +function workspaceDetailLink(input: { + projectId: string | null | undefined; + issueProjectWorkspaceId: string | null | undefined; + workspace: ExecutionWorkspace | null | undefined; +}) { + const linkedProjectWorkspaceId = input.workspace?.projectWorkspaceId ?? input.issueProjectWorkspaceId ?? null; + if (input.workspace?.mode === "shared_workspace") { + return projectWorkspaceDetailLink({ + projectId: input.projectId, + projectWorkspaceId: linkedProjectWorkspaceId, + }); + } + return input.workspace ? `/execution-workspaces/${input.workspace.id}` : null; +} + function statusBadge(status: string) { const colors: Record = { active: "bg-green-500/15 text-green-700 dark:text-green-400", @@ -209,6 +232,17 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId) ?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null); + const selectedReusableWorkspaceLink = workspaceDetailLink({ + projectId: project?.id, + issueProjectWorkspaceId: issue.projectWorkspaceId, + workspace: selectedReusableExecutionWorkspace, + }); + const currentWorkspaceLink = workspaceDetailLink({ + projectId: project?.id, + issueProjectWorkspaceId: issue.projectWorkspaceId, + workspace, + }); + const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; const handleSave = useCallback(() => { @@ -317,18 +351,22 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC {currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
Reusing:{" "} - + {selectedReusableWorkspaceLink ? ( + + + + ) : ( - + )}
)} - {workspace && ( + {workspace && currentWorkspaceLink && (
View workspace details → @@ -385,12 +423,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
Current:{" "} - + {currentWorkspaceLink ? ( + + + + ) : ( - + )} {" · "} {workspace.status}
diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 0bbbaabc..8aaa7581 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -118,6 +118,80 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => { }); }); +describe("LiveUpdatesProvider run lifecycle toasts", () => { + it("does not build start or success toasts for agent runs", () => { + const queryClient = { + getQueryData: () => [], + }; + + expect( + __liveUpdatesTestUtils.buildAgentStatusToast( + { + agentId: "agent-1", + status: "running", + }, + () => "CodexCoder", + queryClient as never, + "company-1", + ), + ).toBeNull(); + + expect( + __liveUpdatesTestUtils.buildRunStatusToast( + { + runId: "run-1", + agentId: "agent-1", + status: "succeeded", + }, + () => "CodexCoder", + ), + ).toBeNull(); + }); + + it("still builds failure toasts for agent errors and failed runs", () => { + const queryClient = { + getQueryData: () => [ + { + id: "agent-1", + title: "Software Engineer", + }, + ], + }; + + expect( + __liveUpdatesTestUtils.buildAgentStatusToast( + { + agentId: "agent-1", + status: "error", + }, + () => "CodexCoder", + queryClient as never, + "company-1", + ), + ).toMatchObject({ + title: "CodexCoder errored", + body: "Software Engineer", + tone: "error", + }); + + expect( + __liveUpdatesTestUtils.buildRunStatusToast( + { + runId: "run-1", + agentId: "agent-1", + status: "failed", + error: "boom", + }, + () => "CodexCoder", + ), + ).toMatchObject({ + title: "CodexCoder run failed", + body: "boom", + tone: "error", + }); + }); +}); + describe("LiveUpdatesProvider socket helpers", () => { it("waits for the selected company object to catch up before connecting", () => { expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull(); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index b4f83388..71e6fbc8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -255,8 +255,8 @@ function shouldSuppressAgentStatusToastForVisibleIssue( } const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); -const AGENT_TOAST_STATUSES = new Set(["running", "error"]); -const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]); +const AGENT_TOAST_STATUSES = new Set(["error"]); +const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); function describeIssueUpdate(details: Record | null): string | null { if (!details) return null; @@ -427,7 +427,7 @@ function buildRunStatusToast( const runId = readString(payload.runId); const agentId = readString(payload.agentId); const status = readString(payload.status); - if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null; + if (!runId || !agentId || !status || !RUN_TOAST_STATUSES.has(status)) return null; const error = readString(payload.error); const triggerDetail = readString(payload.triggerDetail); @@ -703,6 +703,8 @@ function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string } export const __liveUpdatesTestUtils = { + buildAgentStatusToast, + buildRunStatusToast, closeSocketQuietly, invalidateActivityQueries, resolveLiveCompanyId, diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts new file mode 100644 index 00000000..d6dc2668 --- /dev/null +++ b/ui/src/lib/company-routes.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + applyCompanyPrefix, + extractCompanyPrefixFromPath, + isBoardPathWithoutPrefix, + toCompanyRelativePath, +} from "./company-routes"; + +describe("company routes", () => { + it("treats execution workspace paths as board routes that need a company prefix", () => { + expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true); + expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull(); + expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe( + "/PAP/execution-workspaces/workspace-123", + ); + }); + + it("normalizes prefixed execution workspace paths back to company-relative paths", () => { + expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe( + "/execution-workspaces/workspace-123", + ); + }); +}); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index b8d51fd5..48d71ef2 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "org", "agents", "projects", + "execution-workspaces", "issues", "routines", "goals", diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts new file mode 100644 index 00000000..0dcb70c8 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab"; + +function createProjectWorkspace(overrides: Partial): ProjectWorkspace { + return { + id: overrides.id ?? "workspace-default", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + name: overrides.name ?? "paperclip", + sourceType: overrides.sourceType ?? "local_path", + cwd: overrides.cwd ?? "/repo", + repoUrl: overrides.repoUrl ?? null, + repoRef: overrides.repoRef ?? null, + defaultRef: overrides.defaultRef ?? null, + visibility: overrides.visibility ?? "default", + setupCommand: overrides.setupCommand ?? null, + cleanupCommand: overrides.cleanupCommand ?? null, + remoteProvider: overrides.remoteProvider ?? null, + remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null, + sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null, + metadata: overrides.metadata ?? null, + runtimeConfig: overrides.runtimeConfig ?? null, + isPrimary: overrides.isPrimary ?? false, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + }; +} + +function createIssue(overrides: Partial): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + goalId: overrides.goalId ?? null, + parentId: overrides.parentId ?? null, + title: overrides.title ?? "Issue", + description: overrides.description ?? null, + status: overrides.status ?? "todo", + priority: overrides.priority ?? "medium", + assigneeAgentId: overrides.assigneeAgentId ?? null, + assigneeUserId: overrides.assigneeUserId ?? null, + checkoutRunId: overrides.checkoutRunId ?? null, + executionRunId: overrides.executionRunId ?? null, + executionAgentNameKey: overrides.executionAgentNameKey ?? null, + executionLockedAt: overrides.executionLockedAt ?? null, + createdByAgentId: overrides.createdByAgentId ?? null, + createdByUserId: overrides.createdByUserId ?? null, + issueNumber: overrides.issueNumber ?? null, + identifier: overrides.identifier ?? null, + requestDepth: overrides.requestDepth ?? 0, + billingCode: overrides.billingCode ?? null, + assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + executionWorkspacePreference: overrides.executionWorkspacePreference ?? null, + executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + cancelledAt: overrides.cancelledAt ?? null, + hiddenAt: overrides.hiddenAt ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + } as Issue; +} + +function createExecutionWorkspace(overrides: Partial): ExecutionWorkspace { + return { + id: overrides.id ?? "exec-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default", + sourceIssueId: overrides.sourceIssueId ?? null, + mode: overrides.mode ?? "isolated_workspace", + strategyType: overrides.strategyType ?? "git_worktree", + name: overrides.name ?? "PAP-893", + status: overrides.status ?? "active", + cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893", + repoUrl: overrides.repoUrl ?? null, + baseRef: overrides.baseRef ?? "public-gh/master", + branchName: overrides.branchName ?? "PAP-893-workspaces-tab", + providerType: overrides.providerType ?? "git_worktree", + providerRef: overrides.providerRef ?? null, + derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"), + openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"), + closedAt: overrides.closedAt ?? null, + cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, + cleanupReason: overrides.cleanupReason ?? null, + config: overrides.config ?? null, + metadata: overrides.metadata ?? null, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), + }; +} + +describe("buildProjectWorkspaceSummaries", () => { + const primaryWorkspace = createProjectWorkspace({ + id: "workspace-default", + isPrimary: true, + name: "paperclip", + }); + const featureWorkspace = createProjectWorkspace({ + id: "workspace-feature", + name: "feature-checkout", + repoRef: "feature/workspaces", + updatedAt: new Date("2026-03-25T09:00:00Z"), + }); + const project = { + workspaces: [primaryWorkspace, featureWorkspace], + primaryWorkspace, + } satisfies Pick; + + it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-primary", + projectWorkspaceId: primaryWorkspace.id, + updatedAt: new Date("2026-03-26T08:00:00Z"), + }), + createIssue({ + id: "issue-feature-older", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-800", + updatedAt: new Date("2026-03-25T10:00:00Z"), + }), + createIssue({ + id: "issue-feature-newer", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-801", + updatedAt: new Date("2026-03-25T11:00:00Z"), + }), + createIssue({ + id: "issue-exec", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-1", + identifier: "PAP-893", + updatedAt: new Date("2026-03-26T11:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-1", + name: "PAP-893", + branchName: "PAP-893-workspaces-tab", + lastUsedAt: new Date("2026-03-26T10:30:00Z"), + }), + ], + }); + + expect(summaries).toHaveLength(3); + expect(summaries[0]).toMatchObject({ + key: "execution:exec-1", + kind: "execution_workspace", + workspaceName: "PAP-893", + branchName: "PAP-893-workspaces-tab", + executionWorkspaceId: "exec-1", + }); + expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]); + + expect(summaries[1]).toMatchObject({ + key: "project:workspace-feature", + kind: "project_workspace", + workspaceName: "feature-checkout", + branchName: "feature/workspaces", + projectWorkspaceId: "workspace-feature", + }); + expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([ + "issue-feature-newer", + "issue-feature-older", + ]); + expect(summaries[2]?.key).toBe("project:workspace-default"); + }); + + it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-exec-derived", + projectWorkspaceId: featureWorkspace.id, + executionWorkspaceId: "exec-2", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-2", + projectWorkspaceId: featureWorkspace.id, + name: "feature-branch run", + }), + ], + }); + + expect(summaries).toHaveLength(2); + expect(summaries[0]?.key).toBe("execution:exec-2"); + expect(summaries[1]?.key).toBe("project:workspace-default"); + }); + + it("excludes issues that only use the default shared workspace", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-default-shared", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-shared-default", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-shared-default", + mode: "shared_workspace", + strategyType: "project_primary", + projectWorkspaceId: primaryWorkspace.id, + branchName: null, + baseRef: null, + providerType: "local_fs", + }), + ], + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.key).toBe("project:workspace-default"); + }); +}); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts new file mode 100644 index 00000000..df0d9bb3 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.ts @@ -0,0 +1,172 @@ +import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared"; + +type ProjectWorkspaceLike = Pick; + +export interface ProjectWorkspaceSummary { + key: string; + kind: "execution_workspace" | "project_workspace"; + workspaceId: string; + workspaceName: string; + cwd: string | null; + branchName: string | null; + lastUpdatedAt: Date; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; + executionWorkspaceStatus: ExecutionWorkspace["status"] | null; + serviceCount: number; + runningServiceCount: number; + primaryServiceUrl: string | null; + hasRuntimeConfig: boolean; + issues: Issue[]; +} + +function toDate(value: Date | string | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function maxDate(...values: Array): Date { + let latest = new Date(0); + for (const value of values) { + const date = toDate(value); + if (date && date.getTime() > latest.getTime()) latest = date; + } + return latest; +} + +function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { + return project.primaryWorkspace?.id + ?? project.workspaces.find((workspace) => workspace.isPrimary)?.id + ?? project.workspaces[0]?.id + ?? null; +} + +function isDefaultSharedExecutionWorkspace(input: { + executionWorkspace: ExecutionWorkspace; + issue: Issue; + primaryWorkspaceId: string | null; +}) { + const linkedProjectWorkspaceId = + input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; + return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId; +} + +export function buildProjectWorkspaceSummaries(input: { + project: ProjectWorkspaceLike; + issues: Issue[]; + executionWorkspaces: ExecutionWorkspace[]; +}): ProjectWorkspaceSummary[] { + const primaryId = primaryWorkspaceId(input.project); + const executionWorkspacesById = new Map( + input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const projectWorkspacesById = new Map( + input.project.workspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const summaries = new Map(); + + for (const issue of input.issues) { + if (issue.executionWorkspaceId) { + const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); + if (!executionWorkspace) continue; + if (executionWorkspace.status === "archived") continue; + if (isDefaultSharedExecutionWorkspace({ + executionWorkspace, + issue, + primaryWorkspaceId: primaryId, + })) continue; + + const existing = summaries.get(`execution:${executionWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`execution:${executionWorkspace.id}`, { + key: `execution:${executionWorkspace.id}`, + kind: "execution_workspace", + workspaceId: executionWorkspace.id, + workspaceName: executionWorkspace.name, + cwd: executionWorkspace.cwd ?? null, + branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, + lastUpdatedAt: maxDate( + existing?.lastUpdatedAt, + executionWorkspace.lastUsedAt, + executionWorkspace.updatedAt, + issue.updatedAt, + ), + projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, + executionWorkspaceId: executionWorkspace.id, + executionWorkspaceStatus: executionWorkspace.status, + serviceCount: executionWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean( + executionWorkspace.config?.workspaceRuntime + ?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime, + ), + issues: nextIssues, + }); + continue; + } + + if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue; + const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId); + if (!projectWorkspace) continue; + + const existing = summaries.get(`project:${projectWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`project:${projectWorkspace.id}`, { + key: `project:${projectWorkspace.id}`, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), + issues: nextIssues, + }); + } + + for (const projectWorkspace of input.project.workspaces) { + const key = `project:${projectWorkspace.id}`; + if (summaries.has(key)) continue; + const shouldSurfaceWorkspace = + projectWorkspace.isPrimary + || Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime) + || (projectWorkspace.runtimeServices?.length ?? 0) > 0; + if (!shouldSurfaceWorkspace) continue; + summaries.set(key, { + key, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(projectWorkspace.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), + issues: [], + }); + } + + return [...summaries.values()].sort((a, b) => { + const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); + return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); + }); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 8b7f2cd7..61d01d39 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -39,6 +39,8 @@ export const queryKeys = { labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, + listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => + ["issues", companyId, "execution-workspace", executionWorkspaceId] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, @@ -61,6 +63,8 @@ export const queryKeys = { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, + closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, + workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index dcab46c1..76e18846 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string { return `/projects/${projectRouteRef(project)}`; } + +/** Build a project workspace URL scoped under its project. */ +export function projectWorkspaceUrl( + project: { id: string; urlKey?: string | null; name?: string | null }, + workspaceId: string, +): string { + return `${projectUrl(project)}/workspaces/${workspaceId}`; +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index e4f3da31..c0db7f88 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1,8 +1,33 @@ +import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; -import { ExternalLink } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { CopyText } from "../components/CopyText"; +import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; + +type WorkspaceFormState = { + name: string; + cwd: string; + repoUrl: string; + baseRef: string; + branchName: string; + providerRef: string; + provisionCommand: string; + teardownCommand: string; + cleanupCommand: string; + inheritRuntime: boolean; + workspaceRuntime: string; +}; function isSafeExternalUrl(value: string | null | undefined) { if (!value) return false; @@ -14,69 +39,829 @@ function isSafeExternalUrl(value: string | null | undefined) { } } +function readText(value: string | null | undefined) { + return value ?? ""; +} + +function formatJson(value: Record | null | undefined) { + if (!value || Object.keys(value).length === 0) return ""; + return JSON.stringify(value, null, 2); +} + +function normalizeText(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parseWorkspaceRuntimeJson(value: string) { + const trimmed = value.trim(); + if (!trimmed) return { ok: true as const, value: null as Record | null }; + + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false as const, + error: "Workspace runtime JSON must be a JSON object.", + }; + } + return { ok: true as const, value: parsed as Record }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : "Invalid JSON.", + }; + } +} + +function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormState { + return { + name: workspace.name, + cwd: readText(workspace.cwd), + repoUrl: readText(workspace.repoUrl), + baseRef: readText(workspace.baseRef), + branchName: readText(workspace.branchName), + providerRef: readText(workspace.providerRef), + provisionCommand: readText(workspace.config?.provisionCommand), + teardownCommand: readText(workspace.config?.teardownCommand), + cleanupCommand: readText(workspace.config?.cleanupCommand), + inheritRuntime: !workspace.config?.workspaceRuntime, + workspaceRuntime: formatJson(workspace.config?.workspaceRuntime), + }; +} + +function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { + const patch: Record = {}; + const configPatch: Record = {}; + + const maybeAssign = ( + key: keyof Pick, + ) => { + if (initialState[key] === nextState[key]) return; + patch[key] = key === "name" ? (normalizeText(nextState[key]) ?? initialState.name) : normalizeText(nextState[key]); + }; + + maybeAssign("name"); + maybeAssign("cwd"); + maybeAssign("repoUrl"); + maybeAssign("baseRef"); + maybeAssign("branchName"); + maybeAssign("providerRef"); + + const maybeAssignConfigText = (key: keyof Pick) => { + if (initialState[key] === nextState[key]) return; + configPatch[key] = normalizeText(nextState[key]); + }; + + maybeAssignConfigText("provisionCommand"); + maybeAssignConfigText("teardownCommand"); + maybeAssignConfigText("cleanupCommand"); + + if (initialState.inheritRuntime !== nextState.inheritRuntime || initialState.workspaceRuntime !== nextState.workspaceRuntime) { + const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime); + if (!parsed.ok) throw new Error(parsed.error); + configPatch.workspaceRuntime = nextState.inheritRuntime ? null : parsed.value; + } + + if (Object.keys(configPatch).length > 0) { + patch.config = configPatch; + } + + return patch; +} + +function validateForm(form: WorkspaceFormState) { + const repoUrl = normalizeText(form.repoUrl); + if (repoUrl) { + try { + new URL(repoUrl); + } catch { + return "Repo URL must be a valid URL."; + } + } + + if (!form.inheritRuntime) { + const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime); + if (!runtimeJson.ok) { + return runtimeJson.error; + } + } + + return null; +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
-
{label}
+
+
{label}
{children}
); } +function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function MonoValue({ value, copy }: { value: string; copy?: boolean }) { + return ( +
+ {value} + {copy ? ( + + + + ) : null} +
+ ); +} + +function WorkspaceLink({ + project, + workspace, +}: { + project: Project; + workspace: ProjectWorkspace; +}) { + return {workspace.name}; +} + export function ExecutionWorkspaceDetail() { const { workspaceId } = useParams<{ workspaceId: string }>(); + const queryClient = useQueryClient(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + const [form, setForm] = useState(null); + const [closeDialogOpen, setCloseDialogOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); - const { data: workspace, isLoading, error } = useQuery({ + const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryFn: () => executionWorkspacesApi.get(workspaceId!), enabled: Boolean(workspaceId), }); + const workspace = workspaceQuery.data ?? null; - if (isLoading) return

Loading...

; - if (error) return

{error instanceof Error ? error.message : "Failed to load workspace"}

; - if (!workspace) return null; + const projectQuery = useQuery({ + queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"], + queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId), + enabled: Boolean(workspace?.projectId), + }); + const project = projectQuery.data ?? null; + + const sourceIssueQuery = useQuery({ + queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"], + queryFn: () => issuesApi.get(workspace!.sourceIssueId!), + enabled: Boolean(workspace?.sourceIssueId), + }); + const sourceIssue = sourceIssueQuery.data ?? null; + + const derivedWorkspaceQuery = useQuery({ + queryKey: workspace?.derivedFromExecutionWorkspaceId + ? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId) + : ["execution-workspaces", "detail", "__none__"], + queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!), + enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId), + }); + const derivedWorkspace = derivedWorkspaceQuery.data ?? null; + const linkedIssuesQuery = useQuery({ + queryKey: workspace + ? queryKeys.issues.listByExecutionWorkspace(workspace.companyId, workspace.id) + : ["issues", "__execution-workspace__", "__none__"], + queryFn: () => issuesApi.list(workspace!.companyId, { executionWorkspaceId: workspace!.id }), + enabled: Boolean(workspace?.companyId), + }); + const linkedIssues = linkedIssuesQuery.data ?? []; + + const linkedProjectWorkspace = useMemo( + () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, + [project, workspace?.projectWorkspaceId], + ); + const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; + const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; + const runtimeConfigSource = + workspace?.config?.workspaceRuntime + ? "execution_workspace" + : inheritedRuntimeConfig + ? "project_workspace" + : "none"; + + const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); + const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? ""; + + useEffect(() => { + if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return; + setSelectedCompanyId(workspace.companyId, { source: "route_sync" }); + }, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]); + + useEffect(() => { + if (!workspace) return; + setForm(formStateFromWorkspace(workspace)); + setErrorMessage(null); + }, [workspace]); + + useEffect(() => { + if (!workspace) return; + const crumbs = [ + { label: "Projects", href: "/projects" }, + ...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []), + ...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []), + { label: workspace.name }, + ]; + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, workspace, project, projectRef]); + + const updateWorkspace = useMutation({ + mutationFn: (patch: Record) => executionWorkspacesApi.update(workspace!.id, patch), + onSuccess: (nextWorkspace) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(nextWorkspace.id) }); + if (project) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); + } + if (sourceIssue) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) }); + } + setErrorMessage(null); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace."); + }, + }); + const workspaceOperationsQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspaceId!), + queryFn: () => executionWorkspacesApi.listWorkspaceOperations(workspaceId!), + enabled: Boolean(workspaceId), + }); + const controlRuntimeServices = useMutation({ + mutationFn: (action: "start" | "stop" | "restart") => + executionWorkspacesApi.controlRuntimeServices(workspace!.id, action), + onSuccess: (result, action) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) }); + setErrorMessage(null); + setRuntimeActionMessage( + action === "stop" + ? "Runtime services stopped." + : action === "restart" + ? "Runtime services restarted." + : "Runtime services started.", + ); + }, + onError: (error) => { + setRuntimeActionMessage(null); + setErrorMessage(error instanceof Error ? error.message : "Failed to control runtime services."); + }, + }); + + if (workspaceQuery.isLoading) return

Loading workspace…

; + if (workspaceQuery.error) { + return ( +

+ {workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"} +

+ ); + } + if (!workspace || !form || !initialState) return null; + + const saveChanges = () => { + const validationError = validateForm(form); + if (validationError) { + setErrorMessage(validationError); + return; + } + + let patch: Record; + try { + patch = buildWorkspacePatch(initialState, form); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to build workspace update."); + return; + } + + if (Object.keys(patch).length === 0) return; + updateWorkspace.mutate(patch); + }; return ( -
-
-
Execution workspace
-

{workspace.name}

-
- {workspace.status} · {workspace.mode} · {workspace.providerType} + <> +
+
+ + {workspace.mode} + {workspace.providerType} + + {workspace.status} + +
+ +
+
+
+
+
+
+ Execution workspace +
+

{workspace.name}

+

+ Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay + attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, + and runtime-service behavior in sync with the actual workspace being reused. +

+
+
+ +
+
+ + + +
+ + setForm((current) => current ? { ...current, name: event.target.value } : current)} + placeholder="Execution workspace name" + /> + + + setForm((current) => current ? { ...current, branchName: event.target.value } : current)} + placeholder="PAP-946-workspace" + /> + +
+ +
+ + setForm((current) => current ? { ...current, cwd: event.target.value } : current)} + placeholder="/absolute/path/to/workspace" + /> + + + setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} + placeholder="/path/to/worktree or provider ref" + /> + +
+ +
+ + setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} + placeholder="https://github.com/org/repo" + /> + + + setForm((current) => current ? { ...current, baseRef: event.target.value } : current)} + placeholder="origin/main" + /> + +
+ +
+ +