Merge pull request #2074 from paperclipai/pap-979-runtime-workspaces

feat: expand execution workspace runtime controls
This commit is contained in:
Dotta 2026-03-30 08:35:50 -05:00 committed by GitHub
commit c610192c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 7094 additions and 245 deletions

View file

@ -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:

View file

@ -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",

View file

@ -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.

View file

@ -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",

View file

@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record<string, string>): Record<string, st
return redacted;
}
export function buildInvocationEnvForLogs(
env: Record<string, string>,
options: {
runtimeEnv?: NodeJS.ProcessEnv | Record<string, string>;
includeRuntimeKeys?: string[];
resolvedCommand?: string | null;
resolvedCommandEnvKey?: string;
} = {},
): Record<string, string> {
const merged: Record<string, string> = { ...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<string, string> {
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<string> {
return (await resolveCommandPath(command, cwd, env)) ?? command;
}
function quoteForCmd(arg: string) {
if (!arg.length) return '""';
const escaped = arg.replace(/"/g, '""');

View file

@ -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

View file

@ -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<string, string>;
loggedEnv: Record<string, string>;
timeoutSec: number;
graceSec: number;
extraArgs: string[];
@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -247,11 +256,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
return {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@ -324,11 +335,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const {
command,
resolvedCommand,
cwd,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
@ -440,11 +453,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "claude_local",
command,
command: resolvedCommand,
cwd,
commandArgs: args,
commandNotes,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -32,7 +32,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 Codex 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

View file

@ -9,12 +9,13 @@ import {
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
renderTemplate,
joinPromptSections,
@ -383,6 +384,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -490,14 +497,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -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<AdapterExec
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -383,11 +390,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "cursor",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -10,16 +10,17 @@ import {
asString,
asStringArray,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
readPaperclipRuntimeSkillEntries,
resolveCommandForLogs,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@ -220,6 +221,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
@ -333,13 +340,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "gemini_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: redactEnvForLogs(env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -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

View file

@ -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<AdapterExec
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
await ensureOpenCodeModelConfiguredAndAvailable({
model,
@ -298,11 +305,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(preparedRuntimeConfig.env),
env: loggedEnv,
prompt,
promptMetrics,
context,

View file

@ -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<AdapterExec
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
// Validate model is available before execution
await ensurePiModelConfiguredAndAvailable({
@ -356,11 +363,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "pi_local",
command,
command: resolvedCommand,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
prompt: userPrompt,
promptMetrics,
context,

View file

@ -188,10 +188,19 @@ export type {
ProjectGoalRef,
ProjectWorkspace,
ExecutionWorkspace,
ExecutionWorkspaceConfig,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseActionKind,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseLinkedIssue,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
WorkspaceRuntimeDesiredState,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,
@ -388,6 +397,12 @@ export {
issueWorkProductReviewStateSchema,
updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema,
executionWorkspaceCloseActionKindSchema,
executionWorkspaceCloseActionSchema,
executionWorkspaceCloseGitReadinessSchema,
executionWorkspaceCloseLinkedIssueSchema,
executionWorkspaceCloseReadinessSchema,
executionWorkspaceCloseReadinessStateSchema,
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,

View file

@ -50,7 +50,16 @@ export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
ExecutionWorkspace,
ExecutionWorkspaceConfig,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseActionKind,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseLinkedIssue,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceCloseReadinessState,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
WorkspaceRuntimeDesiredState,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceProviderType,

View file

@ -1,5 +1,9 @@
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
import type {
ProjectExecutionWorkspacePolicy,
ProjectWorkspaceRuntimeConfig,
WorkspaceRuntimeService,
} from "./workspace-runtime.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
@ -26,6 +30,7 @@ export interface ProjectWorkspace {
remoteWorkspaceRef: string | null;
sharedWorkspaceKey: string | null;
metadata: Record<string, unknown> | null;
runtimeConfig: ProjectWorkspaceRuntimeConfig | null;
isPrimary: boolean;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date;

View file

@ -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<string, unknown> | null;
desiredState: WorkspaceRuntimeDesiredState | null;
}
export interface ProjectWorkspaceRuntimeConfig {
workspaceRuntime: Record<string, unknown> | 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<string, unknown> | null;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date;
updatedAt: Date;
}

View file

@ -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();

View file

@ -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";

View file

@ -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<string, unknown>, ctx: z.RefinementCtx) {

656
scripts/dev-runner.ts Normal file
View file

@ -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<string>();
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<typeof spawn> | null = null;
let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null;
let scanTimer: ReturnType<typeof setInterval> | null = null;
let autoRestartTimer: ReturnType<typeof setInterval> | 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<string, string>, absolutePath: string) {
const relativePath = toRelativePath(absolutePath);
if (ignoredRelativePaths.has(relativePath)) return;
if (!shouldTrackDevServerPath(relativePath)) return;
snapshot.set(relativePath, readSignature(absolutePath));
}
function walkDirectory(snapshot: Map<string, string>, 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<string, string>();
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<string, string>, next: Map<string, string>) {
const changed = new Set<string>();
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<string, unknown>) {
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);
}

View file

@ -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,
};
}

44
scripts/dev-service.ts Normal file
View file

@ -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<ReturnType<typeof listLocalServiceRegistryRecords>>) {
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);

View file

@ -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."

View file

@ -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<void> {
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<string, string> = {};
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 });
}
});
});

View file

@ -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<string, string> = {};
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");

View file

@ -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<typeof createDb>;
let svc!: ReturnType<typeof executionWorkspaceService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempDirs = new Set<string>();
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);
});

View file

@ -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);

View file

@ -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";

View file

@ -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<string>();
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
@ -128,6 +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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterAll(async () => {
await tempDb?.cleanup();
});
afterEach(async () => {
await db.delete(workspaceRuntimeServices);
await db.delete(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");

View file

@ -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<AdapterExec
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 15);
@ -28,10 +37,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (onMeta) {
await onMeta({
adapterType: "process",
command,
command: resolvedCommand,
cwd,
commandArgs: args,
env: redactEnvForLogs(env),
env: loggedEnv,
});
}

View file

@ -17,11 +17,13 @@ export {
resolvePathValue,
renderTemplate,
redactEnvForLogs,
buildInvocationEnvForLogs,
buildPaperclipEnv,
defaultPathForPlatform,
ensurePathInEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
resolveCommandForLogs,
} from "@paperclipai/adapter-utils/server-utils";
// Re-export runChildProcess with the server's pino logger wired in.

View file

@ -5,15 +5,16 @@ import { issues, projects, projectWorkspaces } from "@paperclipai/db";
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
import {
cleanupExecutionWorkspaceArtifacts,
startRuntimeServicesForWorkspaceControl,
stopRuntimeServicesForExecutionWorkspace,
} from "../services/workspace-runtime.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
@ -43,6 +44,202 @@ export function executionWorkspaceRoutes(db: Db) {
res.json(workspace);
});
router.get("/execution-workspaces/:id/close-readiness", 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 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<string, unknown> | 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<string, unknown> | 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<string, unknown> = {
...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<string, unknown> | null)
: (req.body.metadata as Record<string, unknown> | 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<string, unknown> | null | undefined) ?? (existing.metadata as Record<string, unknown> | 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,

View file

@ -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,

View file

@ -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;

View file

@ -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<string, unknown> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null | undefined,
patch: Partial<ExecutionWorkspaceConfig> | null,
): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null) ?? null),
metadata: (row.metadata as Record<string, unknown> | 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<ExecutionWorkspaceCloseReadiness | null> => {
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<string, unknown> | 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) => {

View file

@ -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<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
}) {
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<string, unknown>) {
const nextConfig = { ...config };
delete nextConfig.workspaceRuntime;
return nextConfig;
}
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
const strategy = parseObject(config.workspaceStrategy);
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
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<string, unknown>;
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) {

View file

@ -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";

View file

@ -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));

View file

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

View file

@ -0,0 +1,59 @@
import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? { ...value } : null;
}
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
return value === "running" || value === "stopped" ? value : null;
}
export function readProjectWorkspaceRuntimeConfig(
metadata: Record<string, unknown> | 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<string, unknown> | null | undefined,
patch: Partial<ProjectWorkspaceRuntimeConfig> | null,
): Record<string, unknown> | 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;
}

View file

@ -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<string, unknown> | null;
runtimeConfig?: Partial<ProjectWorkspaceRuntimeConfig> | null;
isPrimary?: boolean;
};
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
@ -149,6 +152,7 @@ function toWorkspace(
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | 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<string, unknown> | null | undefined) ?? null,
metadata:
data.runtimeConfig !== undefined
? mergeProjectWorkspaceRuntimeConfig(
(data.metadata as Record<string, unknown> | null | undefined) ?? null,
data.runtimeConfig ?? null,
)
: (data.metadata as Record<string, unknown> | 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<string, unknown> | null | undefined)
: ((existing.metadata as Record<string, unknown> | null | undefined) ?? null),
data.runtimeConfig ?? null,
)
: data.metadata;
}
const updated = await db.transaction(async (tx) => {
if (data.isPrimary === true) {

View file

@ -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<string>;
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
envFingerprint: string;
serviceKey: string;
profileKind: string;
processGroupId: number | null;
}
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
clearIdleTimer(record);
}
runtimeServicesById.clear();
runtimeServicesByReuseKey.clear();
runtimeServiceLeasesByRun.clear();
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
@ -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<string, unknown>;
templateData: ReturnType<typeof buildTemplateData>;
}) {
const rendered: Record<string, string> = {};
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<string, unknown>;
workspace: RealizedExecutionWorkspace;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
adapterEnv: Record<string, string>;
scopeType: RuntimeServiceRef["scopeType"];
scopeId: string | null;
}): {
serviceName: string;
lifecycle: RuntimeServiceRef["lifecycle"];
command: string;
serviceCwd: string;
envConfig: Record<string, unknown>;
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<string, unknown>;
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<RuntimeServiceRecord> {
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<string, string> = {
...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv,
} as Record<string, string>;
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<string, unknown>) {
const runtime = parseObject(config.workspaceRuntime);
return Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => 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<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RuntimeServiceRef[]> {
const runtime = parseObject(input.config.workspaceRuntime);
const rawServices = Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => 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<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RuntimeServiceRef[]> {
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<string, unknown> | null) ?? null,
healthStatus: "healthy",
reused: true,
db,
child: null,
leaseRunIds: new Set(),
idleTimer: null,
envFingerprint: row.reuseKey ?? "",
serviceKey: adoptedRecord.serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
registerRuntimeService(db, record);
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
runtimeServiceId: row.id,
lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
return { reconciled: staleRows.length };
const now = new Date();
await db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(eq(workspaceRuntimeServices.id, row.id));
const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
runtimeServiceId: row.id,
profileKind: "workspace-runtime",
});
if (registryRecord) {
await removeLocalServiceRegistryRecord(registryRecord.serviceKey);
}
stopped += 1;
}
return { reconciled: rows.length, adopted, stopped };
}
export async function 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<string, unknown> | 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<string, unknown> | 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: {

View file

@ -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() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
@ -337,7 +340,10 @@ export function App() {
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}

View file

@ -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<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
getCloseReadiness: (id: string) =>
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
listWorkspaceOperations: (id: string) =>
api.get<WorkspaceOperation[]>(`/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<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};

View file

@ -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");

View file

@ -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<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),

View file

@ -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 (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
}}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription className="break-words">
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
</DialogDescription>
</DialogHeader>
{readinessQuery.isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking whether this workspace is safe to close...
</div>
) : readinessQuery.error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
</div>
) : readiness ? (
<div className="space-y-4">
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
<div className="font-medium">
{readiness.state === "blocked"
? "Close is blocked"
: readiness.state === "ready_with_warnings"
? "Close is allowed with warnings"
: "Close is ready"}
</div>
<div className="mt-1 text-xs opacity-80">
{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."}
</div>
</div>
{blockingIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking issues</h3>
<div className="space-y-2">
{blockingIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
</Link>
<span className="text-xs text-muted-foreground">{issue.status}</span>
</div>
</div>
))}
</div>
</section>
) : null}
{readiness.blockingReasons.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking reasons</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason, idx) => (
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
{reason}
</li>
))}
</ul>
</section>
) : null}
{readiness.warnings.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Warnings</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.warnings.map((warning, idx) => (
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
{warning}
</li>
))}
</ul>
</section>
) : null}
{readiness.git ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Git status</h3>
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
<div>{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Ahead / behind</div>
<div>
{(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Dirty tracked files</div>
<div>{readiness.git.dirtyEntryCount}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Untracked files</div>
<div>{readiness.git.untrackedEntryCount}</div>
</div>
</div>
</div>
</section>
) : null}
{otherLinkedIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Other linked issues</h3>
<div className="space-y-2">
{otherLinkedIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
</Link>
<span className="text-xs text-muted-foreground">{issue.status}</span>
</div>
</div>
))}
</div>
</section>
) : null}
{readiness.runtimeServices.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Attached runtime services</h3>
<div className="space-y-2">
{readiness.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
</div>
<div className="mt-1 break-words text-xs text-muted-foreground">
{service.url ?? service.command ?? service.cwd ?? "No additional details"}
</div>
</div>
))}
</div>
</section>
) : null}
<section className="space-y-2">
<h3 className="text-sm font-medium">Cleanup actions</h3>
<div className="space-y-2">
{readiness.plannedActions.map((action, index) => (
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-lg bg-background px-3 py-2 font-mono text-xs text-foreground">
{action.command}
</pre>
) : null}
</div>
))}
</div>
</section>
{currentStatus === "cleanup_failed" ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
workspace status if it succeeds.
</div>
) : null}
{currentStatus === "archived" ? (
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
This workspace is already archived.
</div>
) : null}
{readiness.git?.repoRoot ? (
<div className="break-words text-xs text-muted-foreground">
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
{readiness.git.workspacePath ? (
<>
{" · "}Workspace path: <span className="font-mono break-all">{readiness.git.workspacePath}</span>
</>
) : null}
</div>
) : null}
<div className="text-xs text-muted-foreground">
Last checked {formatDateTime(new Date())}
</div>
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={closeWorkspace.isPending}
>
Cancel
</Button>
<Button
variant={currentStatus === "cleanup_failed" ? "default" : "destructive"}
onClick={() => closeWorkspace.mutate()}
disabled={confirmDisabled}
>
{closeWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<string, string> = {
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 && (
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
Reusing:{" "}
<Link
to={`/execution-workspaces/${selectedReusableExecutionWorkspace.id}`}
className="hover:text-foreground hover:underline"
>
{selectedReusableWorkspaceLink ? (
<Link
to={selectedReusableWorkspaceLink}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
</Link>
) : (
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
</Link>
)}
</div>
)}
{workspace && (
{workspace && currentWorkspaceLink && (
<div className="pt-0.5">
<Link
to={`/execution-workspaces/${workspace.id}`}
to={currentWorkspaceLink}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
View workspace details
@ -385,12 +423,16 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
<div style={{ overflowWrap: "anywhere" }}>
Current:{" "}
<Link
to={`/execution-workspaces/${workspace.id}`}
className="hover:text-foreground hover:underline"
>
{currentWorkspaceLink ? (
<Link
to={currentWorkspaceLink}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={workspace.name} />
</Link>
) : (
<BreakablePath text={workspace.name} />
</Link>
)}
{" · "}
{workspace.status}
</div>

View file

@ -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();

View file

@ -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<string, unknown> | 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,

View file

@ -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",
);
});
});

View file

@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"org",
"agents",
"projects",
"execution-workspaces",
"issues",
"routines",
"goals",

View file

@ -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>): 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>): 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>): 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<Project, "workspaces" | "primaryWorkspace">;
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");
});
});

View file

@ -0,0 +1,172 @@
import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared";
type ProjectWorkspaceLike = Pick<Project, "workspaces" | "primaryWorkspace">;
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 | string | null | undefined>): 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<string, ProjectWorkspaceSummary>();
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);
});
}

View file

@ -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<string, string | boolean | undefined>) =>
["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,

View file

@ -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}`;
}

View file

@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> };
} 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<string, unknown> = {};
const configPatch: Record<string, unknown> = {};
const maybeAssign = (
key: keyof Pick<WorkspaceFormState, "name" | "cwd" | "repoUrl" | "baseRef" | "branchName" | "providerRef">,
) => {
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<WorkspaceFormState, "provisionCommand" | "teardownCommand" | "cleanupCommand">) => {
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 (
<label className="space-y-1.5">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
</div>
{children}
</label>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5">
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div>
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
<div className="shrink-0 text-xs text-muted-foreground sm:w-32">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground", className)}>
{children}
</div>
);
}
function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
return (
<div className="inline-flex max-w-full items-start gap-2">
<span className="break-all font-mono text-xs">{value}</span>
{copy ? (
<CopyText text={value} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
) : null}
</div>
);
}
function WorkspaceLink({
project,
workspace,
}: {
project: Project;
workspace: ProjectWorkspace;
}) {
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
}
export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const queryClient = useQueryClient();
const { setBreadcrumbs } = useBreadcrumbs();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(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 <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>;
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<string, unknown>) => 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 <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (workspaceQuery.error) {
return (
<p className="text-sm text-destructive">
{workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"}
</p>
);
}
if (!workspace || !form || !initialState) return null;
const saveChanges = () => {
const validationError = validateForm(form);
if (validationError) {
setErrorMessage(validationError);
return;
}
let patch: Record<string, unknown>;
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 (
<div className="max-w-2xl space-y-4">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Execution workspace</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<div className="text-sm text-muted-foreground">
{workspace.status} · {workspace.mode} · {workspace.providerType}
<>
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to all workspaces
</Link>
</Button>
<StatusPill>{workspace.mode}</StatusPill>
<StatusPill>{workspace.providerType}</StatusPill>
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
{workspace.status}
</StatusPill>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
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.
</p>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto">
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
{workspace.status === "cleanup_failed" ? "Retry close" : "Close workspace"}
</Button>
</div>
</div>
<Separator className="my-5" />
<div className="grid gap-4 md:grid-cols-2">
<Field label="Workspace name">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.name}
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
placeholder="Execution workspace name"
/>
</Field>
<Field label="Branch name" hint="Useful for isolated worktrees">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.branchName}
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
placeholder="PAP-946-workspace"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Working directory">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<Field label="Provider path / ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.providerRef}
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
placeholder="/path/to/worktree or provider ref"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Repo URL">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.repoUrl}
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
placeholder="https://github.com/org/repo"
/>
</Field>
<Field label="Base ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.baseRef}
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh"
/>
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</Field>
</div>
<div className="mt-4 grid gap-4">
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<textarea
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-3 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div>
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Runtime config source
</div>
<p className="mt-1 text-sm text-muted-foreground">
{runtimeConfigSource === "execution_workspace"
? "This execution workspace currently overrides the project workspace runtime config."
: runtimeConfigSource === "project_workspace"
? "This execution workspace is inheriting the project workspace runtime config."
: "No runtime config is currently defined on this execution workspace or its project workspace."}
</p>
</div>
<Button
variant="outline"
className="w-full sm:w-auto"
size="sm"
disabled={!linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime}
onClick={() =>
setForm((current) => current ? {
...current,
inheritRuntime: true,
workspaceRuntime: "",
} : current)
}
>
Reset to inherit
</Button>
</div>
</div>
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings for this execution workspace. Leave this inheriting unless you need a one-off override. If you are missing the right commands, ask your CEO to set them up for you.">
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<input
id="inherit-runtime-config"
type="checkbox"
checked={form.inheritRuntime}
onChange={(event) =>
setForm((current) => current ? { ...current, inheritRuntime: event.target.checked } : current)
}
/>
<label htmlFor="inherit-runtime-config">Inherit project workspace runtime config</label>
</div>
<textarea
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
disabled={form.inheritRuntime}
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
/>
</Field>
</div>
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save changes
</Button>
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={!isDirty || updateWorkspace.isPending}
onClick={() => {
setForm(initialState);
setErrorMessage(null);
setRuntimeActionMessage(null);
}}
>
Reset
</Button>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
<h2 className="text-lg font-semibold">Workspace context</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Project">
{project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
</DetailRow>
<DetailRow label="Project workspace">
{project && linkedProjectWorkspace ? (
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
) : workspace.projectWorkspaceId ? (
<MonoValue value={workspace.projectWorkspaceId} />
) : (
"None"
)}
</DetailRow>
<DetailRow label="Source issue">
{sourceIssue ? (
<Link to={issueUrl(sourceIssue)} className="hover:underline">
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
</Link>
) : workspace.sourceIssueId ? (
<MonoValue value={workspace.sourceIssueId} />
) : (
"None"
)}
</DetailRow>
<DetailRow label="Derived from">
{derivedWorkspace ? (
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline">
{derivedWorkspace.name}
</Link>
) : workspace.derivedFromExecutionWorkspaceId ? (
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
) : (
"None"
)}
</DetailRow>
<DetailRow label="Workspace ID">
<MonoValue value={workspace.id} />
</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
<h2 className="text-lg font-semibold">Concrete location</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Working dir">
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
</DetailRow>
<DetailRow label="Provider ref">
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
</DetailRow>
<DetailRow label="Repo URL">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<div className="inline-flex max-w-full items-start gap-2">
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 break-all hover:underline">
{workspace.repoUrl}
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
</a>
<CopyText text={workspace.repoUrl} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
</div>
) : workspace.repoUrl ? (
<MonoValue value={workspace.repoUrl} copy />
) : (
"None"
)}
</DetailRow>
<DetailRow label="Base ref">
{workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
</DetailRow>
<DetailRow label="Branch">
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
</DetailRow>
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
<DetailRow label="Cleanup">
{workspace.cleanupEligibleAt
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
: "Not scheduled"}
</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
<h2 className="text-lg font-semibold">Attached services</h2>
<p className="text-sm text-muted-foreground">
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none"}
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !effectiveRuntimeConfig || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-3">
{workspace.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{service.serviceName}</div>
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{service.url ? (
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{service.url}
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
{service.port ? <div>Port {service.port}</div> : null}
{service.command ? <MonoValue value={service.command} copy /> : null}
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
</div>
</div>
<StatusPill className="self-start">{service.healthStatus}</StatusPill>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
{effectiveRuntimeConfig
? "No runtime services are currently running for this execution workspace."
: "No runtime config is defined for this execution workspace yet."}
</p>
)}
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Recent operations</div>
<h2 className="text-lg font-semibold">Runtime and cleanup logs</h2>
</div>
<Separator className="my-4" />
{workspaceOperationsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading workspace operations</p>
) : workspaceOperationsQuery.error ? (
<p className="text-sm text-destructive">
{workspaceOperationsQuery.error instanceof Error
? workspaceOperationsQuery.error.message
: "Failed to load workspace operations."}
</p>
) : workspaceOperationsQuery.data && workspaceOperationsQuery.data.length > 0 ? (
<div className="space-y-3">
{workspaceOperationsQuery.data.slice(0, 6).map((operation) => (
<div key={operation.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{operation.command ?? operation.phase}</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(operation.startedAt)}
{operation.finishedAt ? `${formatDateTime(operation.finishedAt)}` : ""}
</div>
{operation.stderrExcerpt ? (
<div className="whitespace-pre-wrap break-words text-xs text-destructive">{operation.stderrExcerpt}</div>
) : operation.stdoutExcerpt ? (
<div className="whitespace-pre-wrap break-words text-xs text-muted-foreground">{operation.stdoutExcerpt}</div>
) : null}
</div>
<StatusPill className="self-start">{operation.status}</StatusPill>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No workspace operations have been recorded yet.</p>
)}
</div>
</div>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked issues</div>
<h2 className="text-lg font-semibold">Issues using this workspace</h2>
<p className="text-sm text-muted-foreground">
Any issue attached to this execution workspace appears here so you can review the full session context before reusing or closing it.
</p>
</div>
<StatusPill>{linkedIssues.length} linked</StatusPill>
</div>
<Separator className="my-4" />
{linkedIssuesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading linked issues</p>
) : linkedIssuesQuery.error ? (
<p className="text-sm text-destructive">
{linkedIssuesQuery.error instanceof Error
? linkedIssuesQuery.error.message
: "Failed to load linked issues."}
</p>
) : linkedIssues.length > 0 ? (
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
{linkedIssues.map((issue) => (
<Link
key={issue.id}
to={issueUrl(issue)}
className="min-w-72 rounded-xl border border-border/80 bg-background px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</div>
<div className="line-clamp-2 text-sm font-medium">{issue.title}</div>
</div>
<StatusPill className="shrink-0">{issue.status}</StatusPill>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="uppercase tracking-[0.16em]">{issue.priority}</span>
<span>{formatDateTime(issue.updatedAt)}</span>
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No issues are currently linked to this execution workspace.</p>
)}
</div>
</div>
<div className="rounded-lg border border-border p-4">
<DetailRow label="Project">
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
</DetailRow>
<DetailRow label="Source issue">
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
</DetailRow>
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
<DetailRow label="Working dir">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
<DetailRow label="Provider ref">
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
</DetailRow>
<DetailRow label="Repo URL">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{workspace.repoUrl}
<ExternalLink className="h-3 w-3" />
</a>
) : workspace.repoUrl ? (
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
) : "None"}
</DetailRow>
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
<DetailRow label="Cleanup">
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
</DetailRow>
</div>
</div>
<ExecutionWorkspaceCloseDialog
workspaceId={workspace.id}
workspaceName={workspace.name}
currentStatus={workspace.status}
open={closeDialogOpen}
onOpenChange={setCloseDialogOpen}
onClosed={(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.executionWorkspaces.list(project.companyId, { projectId: project.id }) });
}
if (sourceIssue) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) });
}
}}
/>
</>
);
}

View file

@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared";
import { budgetsApi } from "../api/budgets";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@ -14,20 +16,26 @@ import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { CopyText } from "../components/CopyText";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { Clock3, Copy, GitBranch, Loader2 } from "lucide-react";
/* ── Top-level tab types ── */
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
@ -44,6 +52,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
if (tab === "configuration") return "configuration";
if (tab === "budget") return "budget";
if (tab === "issues") return "list";
if (tab === "workspaces") return "workspaces";
return null;
}
@ -200,6 +209,241 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
);
}
function ProjectWorkspacesContent({
companyId,
projectId,
projectRef,
summaries,
}: {
companyId: string;
projectId: string;
projectRef: string;
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
}) {
const queryClient = useQueryClient();
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
const [closingWorkspace, setClosingWorkspace] = useState<{
id: string;
name: string;
status: ExecutionWorkspace["status"];
} | null>(null);
const controlWorkspaceRuntime = useMutation({
mutationFn: async (input: {
key: string;
kind: "project_workspace" | "execution_workspace";
workspaceId: string;
action: "start" | "stop" | "restart";
}) => {
setRuntimeActionKey(`${input.key}:${input.action}`);
if (input.kind === "project_workspace") {
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
}
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
},
onSettled: () => {
setRuntimeActionKey(null);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
},
});
if (summaries.length === 0) {
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
}
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
const visibleIssues = summary.issues.slice(0, 3);
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
const workspaceHref =
summary.kind === "project_workspace"
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
: `/execution-workspaces/${summary.workspaceId}`;
return (
<div
key={summary.key}
className="border-b border-border px-4 py-3 last:border-b-0"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
<div className="min-w-0">
<Link
to={workspaceHref}
className="block truncate text-sm font-medium hover:underline"
>
{summary.workspaceName}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.runningServiceCount}/{summary.serviceCount} services running
</span>
{summary.executionWorkspaceStatus ? (
<span className="rounded-full border border-border px-2 py-0.5 text-[11px]">
{summary.executionWorkspaceStatus}
</span>
) : null}
</div>
{summary.primaryServiceUrl ? (
<a
href={summary.primaryServiceUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
>
{summary.primaryServiceUrl}
</a>
) : null}
{summary.cwd ? (
<div className="mt-2 flex min-w-0 items-start gap-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate font-mono leading-tight" title={summary.cwd}>
{summary.cwd}
</span>
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
</div>
) : null}
</div>
<div className="min-w-0">
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
Issues ({summary.issues.length})
</div>
<div className="flex flex-wrap gap-2">
{visibleIssues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-left text-xs leading-none transition-colors hover:bg-accent"
>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate leading-tight">{issue.title}</span>
</Link>
))}
{hiddenIssueCount > 0 ? (
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
... and {hiddenIssueCount} more
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col items-start gap-2 md:items-end">
<Link
to={workspaceHref}
className="text-xs font-medium text-foreground hover:underline"
>
{summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"}
</Link>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={
controlWorkspaceRuntime.isPending
|| !summary.hasRuntimeConfig
|| runtimeActionKey !== null && runtimeActionKey !== `${summary.key}:start`
}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "start",
})
}
>
{runtimeActionKey === `${summary.key}:start` ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
disabled={controlWorkspaceRuntime.isPending || summary.serviceCount === 0}
onClick={() =>
controlWorkspaceRuntime.mutate({
key: summary.key,
kind: summary.kind,
workspaceId: summary.workspaceId,
action: "stop",
})
}
>
Stop
</Button>
</div>
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
<Button
variant="outline"
size="sm"
onClick={() => setClosingWorkspace({
id: summary.executionWorkspaceId!,
name: summary.workspaceName,
status: summary.executionWorkspaceStatus!,
})}
>
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close workspace"}
</Button>
) : null}
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
{timeAgo(summary.lastUpdatedAt)}
</div>
</div>
</div>
</div>
);
};
return (
<>
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border bg-card">
{activeSummaries.map(renderSummaryRow)}
</div>
{cleanupFailedSummaries.length > 0 ? (
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Cleanup attention needed
</div>
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
{cleanupFailedSummaries.map(renderSummaryRow)}
</div>
</div>
) : null}
</div>
{closingWorkspace ? (
<ExecutionWorkspaceCloseDialog
workspaceId={closingWorkspace.id}
workspaceName={closingWorkspace.name}
currentStatus={closingWorkspace.status}
open
onOpenChange={(open) => {
if (!open) setClosingWorkspace(null);
}}
onClosed={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
setClosingWorkspace(null);
}}
/>
) : null}
</>
);
}
/* ── Main project page ── */
export function ProjectDetail() {
@ -241,6 +485,10 @@ export function ProjectDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const projectLookupRef = project?.id ?? routeProjectRef;
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
const experimentalSettingsQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const {
slots: pluginDetailSlots,
isLoading: pluginDetailSlotsLoading,
@ -259,6 +507,39 @@ export function ProjectDetail() {
[pluginDetailSlots],
);
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
const workspaceTabProjectId = project?.id ?? null;
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
queryKey: workspaceTabProjectId && resolvedCompanyId
? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId)
: ["issues", "__workspace-tab__", "disabled"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
});
const {
data: workspaceTabExecutionWorkspaces = [],
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
error: workspaceTabExecutionWorkspacesError,
} = useQuery({
queryKey: workspaceTabProjectId && resolvedCompanyId
? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId })
: ["execution-workspaces", "__workspace-tab__", "disabled"],
queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
});
const workspaceSummaries = useMemo(() => {
if (!project || !isolatedWorkspacesEnabled) return [];
return buildProjectWorkspaceSummaries({
project,
issues: workspaceTabIssues,
executionWorkspaces: workspaceTabExecutionWorkspaces,
});
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
const workspaceTabDecisionLoaded =
experimentalSettingsQuery.isFetched &&
(!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading));
const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null;
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
@ -345,6 +626,10 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
return;
}
if (activeTab === "workspaces") {
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
return;
}
if (activeTab === "list") {
if (filter) {
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
@ -455,6 +740,10 @@ export function ProjectDetail() {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
// Redirect bare /projects/:id to cached tab or default /issues
if (routeProjectRef && activeTab === null) {
let cachedTab: string | null = null;
@ -470,6 +759,12 @@ export function ProjectDetail() {
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
}
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
return <PageSkeleton variant="detail" />;
}
if (isProjectPluginTab(cachedTab)) {
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
}
@ -491,6 +786,8 @@ export function ProjectDetail() {
}
if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "workspaces") {
navigate(`/projects/${canonicalProjectRef}/workspaces`);
} else if (tab === "budget") {
navigate(`/projects/${canonicalProjectRef}/budget`);
} else if (tab === "configuration") {
@ -561,6 +858,7 @@ export function ProjectDetail() {
items={[
{ value: "list", label: "Issues" },
{ value: "overview", label: "Overview" },
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
{ value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" },
...pluginTabItems.map((item) => ({
@ -589,6 +887,23 @@ export function ProjectDetail() {
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
)}
{activeTab === "workspaces" ? (
workspaceTabDecisionLoaded ? (
workspaceTabError ? (
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
) : (
<ProjectWorkspacesContent
companyId={resolvedCompanyId!}
projectId={project.id}
projectRef={canonicalProjectRef}
summaries={workspaceSummaries}
/>
)
) : (
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
)
) : null}
{activeTab === "configuration" && (
<div className="max-w-4xl">
<ProjectProperties

View file

@ -0,0 +1,673 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ChoosePathButton } from "../components/PathInstructionsModal";
import { projectsApi } from "../api/projects";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
type WorkspaceFormState = {
name: string;
sourceType: ProjectWorkspaceSourceType;
cwd: string;
repoUrl: string;
repoRef: string;
defaultRef: string;
visibility: ProjectWorkspaceVisibility;
setupCommand: string;
cleanupCommand: string;
remoteProvider: string;
remoteWorkspaceRef: string;
sharedWorkspaceKey: string;
runtimeConfig: string;
};
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
{ value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." },
{ value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." },
{ value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." },
];
const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [
{ value: "default", label: "Default" },
{ value: "advanced", label: "Advanced" },
];
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function isAbsolutePath(value: string) {
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
}
function readText(value: string | null | undefined) {
return value ?? "";
}
function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
}
function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState {
return {
name: workspace.name,
sourceType: workspace.sourceType,
cwd: readText(workspace.cwd),
repoUrl: readText(workspace.repoUrl),
repoRef: readText(workspace.repoRef),
defaultRef: readText(workspace.defaultRef),
visibility: workspace.visibility,
setupCommand: readText(workspace.setupCommand),
cleanupCommand: readText(workspace.cleanupCommand),
remoteProvider: readText(workspace.remoteProvider),
remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef),
sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey),
runtimeConfig: formatJson(workspace.runtimeConfig?.workspaceRuntime),
};
}
function normalizeText(value: string) {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseRuntimeConfigJson(value: string) {
const trimmed = value.trim();
if (!trimmed) return { ok: true as const, value: null as Record<string, unknown> | null };
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
ok: false as const,
error: "Runtime services JSON must be a JSON object.",
};
}
return { ok: true as const, value: parsed as Record<string, unknown> };
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : "Invalid JSON.",
};
}
}
function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) {
const patch: Record<string, unknown> = {};
const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => {
const initialValue = initialState[key];
const nextValue = nextState[key];
if (initialValue === nextValue) return;
patch[key] = transform ? transform(nextValue) : nextValue;
};
maybeAssign("name", normalizeText);
maybeAssign("sourceType");
maybeAssign("cwd", normalizeText);
maybeAssign("repoUrl", normalizeText);
maybeAssign("repoRef", normalizeText);
maybeAssign("defaultRef", normalizeText);
maybeAssign("visibility");
maybeAssign("setupCommand", normalizeText);
maybeAssign("cleanupCommand", normalizeText);
maybeAssign("remoteProvider", normalizeText);
maybeAssign("remoteWorkspaceRef", normalizeText);
maybeAssign("sharedWorkspaceKey", normalizeText);
if (initialState.runtimeConfig !== nextState.runtimeConfig) {
const parsed = parseRuntimeConfigJson(nextState.runtimeConfig);
if (!parsed.ok) throw new Error(parsed.error);
patch.runtimeConfig = {
workspaceRuntime: parsed.value,
};
}
return patch;
}
function validateWorkspaceForm(form: WorkspaceFormState) {
const cwd = normalizeText(form.cwd);
const repoUrl = normalizeText(form.repoUrl);
const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef);
if (form.sourceType === "remote_managed") {
if (!remoteWorkspaceRef && !repoUrl) {
return "Remote-managed workspaces require a remote workspace ref or repo URL.";
}
} else if (!cwd && !repoUrl) {
return "Workspace requires at least one local path or repo URL.";
}
if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) {
return "Local workspace path must be absolute.";
}
if (repoUrl) {
try {
new URL(repoUrl);
} catch {
return "Repo URL must be a valid URL.";
}
}
const runtimeConfig = parseRuntimeConfigJson(form.runtimeConfig);
if (!runtimeConfig.ok) {
return runtimeConfig.error;
}
return null;
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="space-y-1.5">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
{hint ? <span className="text-[11px] leading-relaxed text-muted-foreground sm:text-right">{hint}</span> : null}
</div>
{children}
</label>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 py-1.5 sm:flex-row sm:items-start sm:gap-3">
<div className="shrink-0 text-xs text-muted-foreground sm:w-28">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div>
</div>
);
}
export function ProjectWorkspaceDetail() {
const { companyPrefix, projectId, workspaceId } = useParams<{
companyPrefix?: string;
projectId: string;
workspaceId: string;
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? "";
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
}, [companies, companyPrefix]);
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
const projectQuery = useQuery({
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
enabled: canFetchProject,
});
const project = projectQuery.data ?? null;
const workspace = useMemo(
() => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null,
[project, routeWorkspaceId],
);
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
setSelectedCompanyId(project.companyId, { source: "route_sync" });
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
useEffect(() => {
if (!workspace) return;
setForm(formStateFromWorkspace(workspace));
setErrorMessage(null);
}, [workspace]);
useEffect(() => {
if (!project) return;
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
{ label: project.name, href: `/projects/${canonicalProjectRef}` },
{ label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` },
{ label: workspace?.name ?? routeWorkspaceId },
]);
}, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]);
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
const invalidateProject = () => {
if (!project) return;
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) });
if (lookupCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) });
}
};
const updateWorkspace = useMutation({
mutationFn: (patch: Record<string, unknown>) =>
projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId),
onSuccess: () => {
invalidateProject();
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace.");
},
});
const setPrimaryWorkspace = useMutation({
mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId),
onSuccess: () => {
invalidateProject();
setErrorMessage(null);
},
onError: (error) => {
setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace.");
},
});
const controlRuntimeServices = useMutation({
mutationFn: (action: "start" | "stop" | "restart") =>
projectsApi.controlWorkspaceRuntimeServices(project!.id, routeWorkspaceId, action, lookupCompanyId),
onSuccess: (result, action) => {
invalidateProject();
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 (projectQuery.isLoading) return <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (projectQuery.error) {
return (
<p className="text-sm text-destructive">
{projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"}
</p>
);
}
if (!project || !workspace || !form || !initialState) {
return <p className="text-sm text-muted-foreground">Workspace not found for this project.</p>;
}
const saveChanges = () => {
const validationError = validateWorkspaceForm(form);
if (validationError) {
setErrorMessage(validationError);
return;
}
const patch = buildWorkspacePatch(initialState, form);
if (Object.keys(patch).length === 0) return;
updateWorkspace.mutate(patch);
};
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={`/projects/${canonicalProjectRef}/workspaces`}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to workspaces
</Link>
</Button>
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Project workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</div>
<Separator className="my-5" />
<div className="grid gap-4 md:grid-cols-2">
<Field label="Workspace name">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.name}
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
placeholder="Workspace name"
/>
</Field>
<Field label="Visibility">
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.visibility}
onChange={(event) =>
setForm((current) => current ? { ...current, visibility: event.target.value as ProjectWorkspaceVisibility } : current)
}
>
{VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
</div>
<div className="mt-4 grid gap-4">
<Field label="Source type" hint={sourceTypeDescription ?? undefined}>
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.sourceType}
onChange={(event) =>
setForm((current) => current ? { ...current, sourceType: event.target.value as ProjectWorkspaceSourceType } : current)
}
>
{SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</Field>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<Field label="Local path">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<div className="flex items-end">
<ChoosePathButton />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Repo URL">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.repoUrl}
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
placeholder="https://github.com/org/repo"
/>
</Field>
<Field label="Repo ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.repoRef}
onChange={(event) => setForm((current) => current ? { ...current, repoRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Default ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.defaultRef}
onChange={(event) => setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
<Field label="Shared workspace key">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.sharedWorkspaceKey}
onChange={(event) => setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)}
placeholder="frontend"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Remote provider">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.remoteProvider}
onChange={(event) => setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)}
placeholder="codespaces"
/>
</Field>
<Field label="Remote workspace ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.remoteWorkspaceRef}
onChange={(event) => setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)}
placeholder="workspace-123"
/>
</Field>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Setup command" hint="Runs when this workspace needs custom bootstrap">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.setupCommand}
onChange={(event) => setForm((current) => current ? { ...current, setupCommand: event.target.value } : current)}
placeholder="pnpm install && pnpm dev"
/>
</Field>
<Field label="Cleanup command" hint="Runs before project-level execution workspace teardown">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
</div>
<Field label="Runtime services JSON" hint="Default runtime services for this workspace. Execution workspaces inherit this config unless they set an override. If you do not know the commands yet, ask your CEO to configure them for you.">
<textarea
className="min-h-36 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.runtimeConfig}
onChange={(event) => setForm((current) => current ? { ...current, runtimeConfig: event.target.value } : current)}
placeholder={"{\n \"services\": [\n {\n \"name\": \"web\",\n \"command\": \"pnpm dev\",\n \"cwd\": \".\",\n \"port\": { \"type\": \"auto\" },\n \"readiness\": {\n \"type\": \"http\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"expose\": {\n \"type\": \"url\",\n \"urlTemplate\": \"http://127.0.0.1:${port}\"\n },\n \"lifecycle\": \"shared\",\n \"reuseScope\": \"project_workspace\"\n }\n ]\n}"}
/>
</Field>
</div>
<div className="mt-5 flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Button className="w-full sm:w-auto" disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save changes
</Button>
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={!isDirty || updateWorkspace.isPending}
onClick={() => {
setForm(initialState);
setErrorMessage(null);
}}
>
Reset
</Button>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{!errorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Workspace facts</div>
<h2 className="text-lg font-semibold">Current state</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Project">
<Link to={`/projects/${canonicalProjectRef}`} className="hover:underline">{project.name}</Link>
</DetailRow>
<DetailRow label="Workspace ID">
<span className="break-all font-mono text-xs">{workspace.id}</span>
</DetailRow>
<DetailRow label="Local path">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
</DetailRow>
<DetailRow label="Repo">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{workspace.repoUrl}
<ExternalLink className="h-3 w-3" />
</a>
) : workspace.repoUrl ? (
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
) : "None"}
</DetailRow>
<DetailRow label="Default ref">{workspace.defaultRef ?? "None"}</DetailRow>
<DetailRow label="Updated">{new Date(workspace.updatedAt).toLocaleString()}</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
<h2 className="text-lg font-semibold">Attached services</h2>
<p className="text-sm text-muted-foreground">
Shared services for this project workspace. Execution workspaces inherit this config unless they override it.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap">
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !workspace.runtimeConfig?.workspaceRuntime || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("start")}
>
{controlRuntimeServices.isPending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Start
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || !workspace.cwd}
onClick={() => controlRuntimeServices.mutate("restart")}
>
Restart
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop
</Button>
</div>
</div>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-3">
{workspace.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{service.serviceName}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{service.url ? (
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{service.url}
<ExternalLink className="h-3 w-3" />
</a>
) : null}
{service.port ? <div>Port {service.port}</div> : null}
<div>{service.command ?? "No command recorded"}</div>
{service.cwd ? <div className="break-all font-mono">{service.cwd}</div> : null}
</div>
</div>
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground sm:text-right">
{service.status} · {service.healthStatus}
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
{workspace.runtimeConfig?.workspaceRuntime
? "No runtime services are currently running for this workspace."
: "No runtime-service default is configured for this workspace yet."}
</p>
)}
</div>
</div>
</div>
</div>
);
}