Merge branch 'master' of github.com-dotta:paperclipai/paperclip

* 'master' of github.com-dotta:paperclipai/paperclip:
  updating paths
  Rebind seeded project workspaces to the current worktree
  Add command-based worktree provisioning
  Refine project and agent configuration UI
  Add configuration tabs to project and agent pages
  Add project-first execution workspace policies
  Add worktree-aware workspace runtime support
This commit is contained in:
Dotta
2026-03-10 14:47:50 -05:00
57 changed files with 16504 additions and 426 deletions

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { copySeededSecretsKey } from "../commands/worktree.js"; import { copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import { import {
buildWorktreeConfig, buildWorktreeConfig,
buildWorktreeEnvEntries, buildWorktreeEnvEntries,
@@ -171,4 +171,32 @@ describe("worktree helpers", () => {
fs.rmSync(tempRoot, { recursive: true, force: true }); fs.rmSync(tempRoot, { recursive: true, force: true });
} }
}); });
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
}); });

View File

@@ -5,10 +5,13 @@ import { execFileSync } from "node:child_process";
import { createServer } from "node:net"; import { createServer } from "node:net";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { eq } from "drizzle-orm";
import { import {
applyPendingMigrations, applyPendingMigrations,
createDb,
ensurePostgresDatabase, ensurePostgresDatabase,
formatDatabaseBackupResult, formatDatabaseBackupResult,
projectWorkspaces,
runDatabaseBackup, runDatabaseBackup,
runDatabaseRestore, runDatabaseRestore,
} from "@paperclipai/db"; } from "@paperclipai/db";
@@ -74,6 +77,20 @@ type EmbeddedPostgresHandle = {
stop: () => Promise<void>; stop: () => Promise<void>;
}; };
type GitWorkspaceInfo = {
root: string;
commonDir: string;
};
type SeedWorktreeDatabaseResult = {
backupSummary: string;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
toCwd: string;
}>;
};
function nonEmpty(value: string | null | undefined): string | null { function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
} }
@@ -133,6 +150,107 @@ function detectGitBranchName(cwd: string): string | null {
} }
} }
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
};
} catch {
return null;
}
}
export function rebindWorkspaceCwd(input: {
sourceRepoRoot: string;
targetRepoRoot: string;
workspaceCwd: string;
}): string | null {
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
const targetRepoRoot = path.resolve(input.targetRepoRoot);
const workspaceCwd = path.resolve(input.workspaceCwd);
const relative = path.relative(sourceRepoRoot, workspaceCwd);
if (!relative || relative === "") {
return targetRepoRoot;
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return path.resolve(targetRepoRoot, relative);
}
async function rebindSeededProjectWorkspaces(input: {
targetConnectionString: string;
currentCwd: string;
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
if (!targetRepo) return [];
const db = createDb(input.targetConnectionString);
const closableDb = db as typeof db & {
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
};
try {
const rows = await db
.select({
id: projectWorkspaces.id,
name: projectWorkspaces.name,
cwd: projectWorkspaces.cwd,
})
.from(projectWorkspaces);
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
for (const row of rows) {
const workspaceCwd = nonEmpty(row.cwd);
if (!workspaceCwd) continue;
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
if (!sourceRepo) continue;
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
const reboundCwd = rebindWorkspaceCwd({
sourceRepoRoot: sourceRepo.root,
targetRepoRoot: targetRepo.root,
workspaceCwd,
});
if (!reboundCwd) continue;
const normalizedCurrent = path.resolve(workspaceCwd);
if (reboundCwd === normalizedCurrent) continue;
if (!existsSync(reboundCwd)) continue;
await db
.update(projectWorkspaces)
.set({
cwd: reboundCwd,
updatedAt: new Date(),
})
.where(eq(projectWorkspaces.id, row.id));
rebound.push({
name: row.name,
fromCwd: normalizedCurrent,
toCwd: reboundCwd,
});
}
return rebound;
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
function resolveSourceConfigPath(opts: WorktreeInitOptions): string { function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
if (opts.fromConfig) return path.resolve(opts.fromConfig); if (opts.fromConfig) return path.resolve(opts.fromConfig);
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
@@ -260,7 +378,7 @@ async function seedWorktreeDatabase(input: {
targetPaths: WorktreeLocalPaths; targetPaths: WorktreeLocalPaths;
instanceId: string; instanceId: string;
seedMode: WorktreeSeedMode; seedMode: WorktreeSeedMode;
}): Promise<string> { }): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
@@ -308,8 +426,15 @@ async function seedWorktreeDatabase(input: {
backupFile: backup.backupFile, backupFile: backup.backupFile,
}); });
await applyPendingMigrations(targetConnectionString); await applyPendingMigrations(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
});
return formatDatabaseBackupResult(backup); return {
backupSummary: formatDatabaseBackupResult(backup),
reboundWorkspaces,
};
} finally { } finally {
if (targetHandle?.startedByThisProcess) { if (targetHandle?.startedByThisProcess) {
await targetHandle.stop(); await targetHandle.stop();
@@ -370,6 +495,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
loadPaperclipEnvFile(paths.configPath); loadPaperclipEnvFile(paths.configPath);
let seedSummary: string | null = null; let seedSummary: string | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) { if (opts.seed !== false) {
if (!sourceConfig) { if (!sourceConfig) {
throw new Error( throw new Error(
@@ -379,7 +505,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
const spinner = p.spinner(); const spinner = p.spinner();
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
try { try {
seedSummary = await seedWorktreeDatabase({ const seeded = await seedWorktreeDatabase({
sourceConfigPath, sourceConfigPath,
sourceConfig, sourceConfig,
targetConfig, targetConfig,
@@ -387,6 +513,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
instanceId, instanceId,
seedMode, seedMode,
}); });
seedSummary = seeded.backupSummary;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`); spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) { } catch (error) {
spinner.stop(pc.red("Failed to seed worktree database.")); spinner.stop(pc.red("Failed to seed worktree database."));
@@ -402,6 +530,11 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
if (seedSummary) { if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`)); p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
);
}
} }
p.outro( p.outro(
pc.green( pc.green(

View File

@@ -168,6 +168,8 @@ paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force paperclipai worktree init --force
``` ```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## Quick Health Checks ## Quick Health Checks
In another terminal: In another terminal:

View File

@@ -0,0 +1,62 @@
# Issue worktree support
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
- project execution workspace policy support
- issue-level execution workspace settings
- git worktree realization for isolated issue execution
- optional command-based worktree provisioning
- seeded worktree fixes for secrets key compatibility
- seeded project workspace rebinding to the current git worktree
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
## What works today
- projects can carry execution workspace policy in the backend
- issues can carry execution workspace settings in the backend
- heartbeat execution can realize isolated git worktrees
- runtime can run a project-defined provision command inside the derived worktree
- seeded worktree instances can keep local-encrypted secrets working
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
## Hidden UI entrypoints
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
- project settings:
- `ui/src/components/ProjectProperties.tsx`
- execution workspace policy controls
- git worktree base ref / branch template / parent dir
- provision / teardown command inputs
- issue creation:
- `ui/src/components/NewIssueDialog.tsx`
- isolated issue checkout toggle
- defaulting issue execution workspace settings from project policy
- issue editing:
- `ui/src/components/IssueProperties.tsx`
- issue-level workspace mode toggle
- defaulting issue execution workspace settings when project changes
- agent/runtime settings:
- `ui/src/adapters/runtime-json-fields.tsx`
- runtime services JSON field, which is part of the broader workspace-runtime support surface
## Why the UI is hidden
- the runtime behavior is still being validated
- the workflow and operator ergonomics are not final
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
## Re-enable plan
When this is ready to ship:
- re-enable the gated UI sections in the files above
- review wording and defaults for project and issue controls
- decide which agent/runtime settings should remain advanced-only
- add end-to-end product-level verification for the full UI workflow

View File

@@ -3,6 +3,7 @@ export type {
AdapterRuntime, AdapterRuntime,
UsageSummary, UsageSummary,
AdapterBillingType, AdapterBillingType,
AdapterRuntimeServiceReport,
AdapterExecutionResult, AdapterExecutionResult,
AdapterInvocationMeta, AdapterInvocationMeta,
AdapterExecutionContext, AdapterExecutionContext,

View File

@@ -32,6 +32,27 @@ export interface UsageSummary {
export type AdapterBillingType = "api" | "subscription" | "unknown"; export type AdapterBillingType = "api" | "subscription" | "unknown";
export interface AdapterRuntimeServiceReport {
id?: string | null;
projectId?: string | null;
projectWorkspaceId?: string | null;
issueId?: string | null;
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId?: string | null;
serviceName: string;
status?: "starting" | "running" | "stopped" | "failed";
lifecycle?: "shared" | "ephemeral";
reuseKey?: string | null;
command?: string | null;
cwd?: string | null;
port?: number | null;
url?: string | null;
providerRef?: string | null;
ownerAgentId?: string | null;
stopPolicy?: Record<string, unknown> | null;
healthStatus?: "unknown" | "healthy" | "unhealthy";
}
export interface AdapterExecutionResult { export interface AdapterExecutionResult {
exitCode: number | null; exitCode: number | null;
signal: string | null; signal: string | null;
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
billingType?: AdapterBillingType | null; billingType?: AdapterBillingType | null;
costUsd?: number | null; costUsd?: number | null;
resultJson?: Record<string, unknown> | null; resultJson?: Record<string, unknown> | null;
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null; summary?: string | null;
clearSession?: boolean; clearSession?: boolean;
} }
@@ -208,6 +230,12 @@ export interface CreateConfigValues {
envBindings: Record<string, unknown>; envBindings: Record<string, unknown>;
url: string; url: string;
bootstrapPrompt: string; bootstrapPrompt: string;
payloadTemplateJson?: string;
workspaceStrategyType?: string;
workspaceBaseRef?: string;
workspaceBranchTemplate?: string;
worktreeParentDir?: string;
runtimeServicesJson?: string;
maxTurnsPerRun: number; maxTurnsPerRun: number;
heartbeatEnabled: boolean; heartbeatEnabled: boolean;
intervalSec: number; intervalSec: number;

View File

@@ -25,8 +25,13 @@ Core fields:
- command (string, optional): defaults to "claude" - command (string, optional): defaults to "claude"
- extraArgs (string[], optional): additional CLI args - extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables - 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
Operational fields: Operational fields:
- timeoutSec (number, optional): run timeout in seconds - timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds - graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`; `;

View File

@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const workspaceContext = parseObject(context.paperclipWorkspace); const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, ""); const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, ""); const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null; const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null; const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null; const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const workspaceHints = Array.isArray(context.paperclipWorkspaces) const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter( ? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null, (value): value is Record<string, unknown> => typeof value === "object" && value !== null,
) )
: []; : [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, ""); const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceSource) { if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
} }
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) { if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId; env.PAPERCLIP_WORKSPACE_ID = workspaceId;
} }
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceRepoRef) { if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
} }
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
} }
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [key, value] of Object.entries(envConfig)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;

View File

@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env; return env;
} }
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> { export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {}; const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd; if (v.cwd) ac.cwd = v.cwd;
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (Object.keys(env).length > 0) ac.env = env; if (Object.keys(env).length > 0) ac.env = env;
ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command; if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac; return ac;

View File

@@ -31,6 +31,8 @@ Core fields:
- command (string, optional): defaults to "codex" - command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args - extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables - 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
Operational fields: Operational fields:
- timeoutSec (number, optional): run timeout in seconds - timeoutSec (number, optional): run timeout in seconds
@@ -40,4 +42,5 @@ Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument). - Prompts are piped via stdin (Codex receives "-" prompt argument).
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills. - Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`; `;

View File

@@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceContext = parseObject(context.paperclipWorkspace); const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, ""); const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, ""); const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, ""); const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, ""); const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces) const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter( ? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null, (value): value is Record<string, unknown> => typeof value === "object" && value !== null,
) )
: []; : [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, ""); const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceSource) { if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
} }
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) { if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId; env.PAPERCLIP_WORKSPACE_ID = workspaceId;
} }
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceRepoRef) { if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
} }
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
} }
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [k, v] of Object.entries(envConfig)) { for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v; if (typeof v === "string") env[k] = v;
} }

View File

@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env; return env;
} }
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> { export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {}; const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd; if (v.cwd) ac.cwd = v.cwd;
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
typeof v.dangerouslyBypassSandbox === "boolean" typeof v.dangerouslyBypassSandbox === "boolean"
? v.dangerouslyBypassSandbox ? v.dangerouslyBypassSandbox
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; : DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command; if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac; return ac;

View File

@@ -31,6 +31,7 @@ Gateway connect identity fields:
Request behavior fields: Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params - 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
- timeoutSec (number, optional): adapter timeout in seconds (default 120) - timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) - 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) - autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
@@ -39,4 +40,15 @@ Request behavior fields:
Session routing fields: Session routing fields:
- sessionKeyStrategy (string, optional): issue (default), fixed, or run - sessionKeyStrategy (string, optional): issue (default), fixed, or run
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) - sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
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
Standard result metadata supported:
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
- meta.previewUrl (string, optional): shorthand single preview URL
- meta.previewUrls (string[], optional): shorthand multiple preview URLs
`; `;

View File

@@ -1,4 +1,8 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import type {
AdapterExecutionContext,
AdapterExecutionResult,
AdapterRuntimeServiceReport,
} from "@paperclipai/adapter-utils";
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
import crypto, { randomUUID } from "node:crypto"; import crypto, { randomUUID } from "node:crypto";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
@@ -411,6 +415,58 @@ function appendWakeText(baseText: string, wakeText: string): string {
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
} }
function buildStandardPaperclipPayload(
ctx: AdapterExecutionContext,
wakePayload: WakePayload,
paperclipEnv: Record<string, string>,
payloadTemplate: Record<string, unknown>,
): Record<string, unknown> {
const templatePaperclip = parseObject(payloadTemplate.paperclip);
const workspace = asRecord(ctx.context.paperclipWorkspace);
const workspaces = Array.isArray(ctx.context.paperclipWorkspaces)
? ctx.context.paperclipWorkspaces.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
: [];
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
? ctx.context.paperclipRuntimeServiceIntents.filter(
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
)
: [];
const standardPaperclip: Record<string, unknown> = {
runId: ctx.runId,
companyId: ctx.agent.companyId,
agentId: ctx.agent.id,
agentName: ctx.agent.name,
taskId: wakePayload.taskId,
issueId: wakePayload.issueId,
issueIds: wakePayload.issueIds,
wakeReason: wakePayload.wakeReason,
wakeCommentId: wakePayload.wakeCommentId,
approvalId: wakePayload.approvalId,
approvalStatus: wakePayload.approvalStatus,
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
};
if (workspace) {
standardPaperclip.workspace = workspace;
}
if (workspaces.length > 0) {
standardPaperclip.workspaces = workspaces;
}
if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) {
standardPaperclip.workspaceRuntime = {
...configuredWorkspaceRuntime,
...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}),
};
}
return {
...templatePaperclip,
...standardPaperclip,
};
}
function normalizeUrl(input: string): URL | null { function normalizeUrl(input: string): URL | null {
try { try {
return new URL(input); return new URL(input);
@@ -835,6 +891,91 @@ function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined
}; };
} }
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
if (!meta) return [];
const reports: AdapterRuntimeServiceReport[] = [];
const runtimeServices = Array.isArray(meta.runtimeServices)
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
: [];
for (const entry of runtimeServices) {
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
if (!serviceName) continue;
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
const status =
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
? rawStatus
: "running";
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
const scopeType =
rawScopeType === "project_workspace" ||
rawScopeType === "execution_workspace" ||
rawScopeType === "agent"
? rawScopeType
: "run";
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
const healthStatus =
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
? rawHealth
: status === "running"
? "healthy"
: "unknown";
reports.push({
id: nonEmpty(entry.id),
projectId: nonEmpty(entry.projectId),
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
issueId: nonEmpty(entry.issueId),
scopeType,
scopeId: nonEmpty(entry.scopeId),
serviceName,
status,
lifecycle,
reuseKey: nonEmpty(entry.reuseKey),
command: nonEmpty(entry.command),
cwd: nonEmpty(entry.cwd),
port: parseOptionalPositiveInteger(entry.port),
url: nonEmpty(entry.url),
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
ownerAgentId: nonEmpty(entry.ownerAgentId),
stopPolicy: asRecord(entry.stopPolicy),
healthStatus,
});
}
const previewUrl = nonEmpty(meta.previewUrl);
if (previewUrl) {
reports.push({
serviceName: "preview",
status: "running",
lifecycle: "ephemeral",
scopeType: "run",
url: previewUrl,
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
healthStatus: "healthy",
});
}
const previewUrls = Array.isArray(meta.previewUrls)
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: [];
previewUrls.forEach((url, index) => {
reports.push({
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
status: "running",
lifecycle: "ephemeral",
scopeType: "run",
url,
providerRef: `${url}#${index}`,
healthStatus: "healthy",
});
});
return reports;
}
function extractResultText(value: unknown): string | null { function extractResultText(value: unknown): string | null {
const record = asRecord(value); const record = asRecord(value);
if (!record) return null; if (!record) return null;
@@ -924,9 +1065,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text); const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText; const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
const agentParams: Record<string, unknown> = { const agentParams: Record<string, unknown> = {
...payloadTemplate, ...payloadTemplate,
paperclip: paperclipPayload,
message, message,
sessionKey, sessionKey,
idempotencyKey: ctx.runId, idempotencyKey: ctx.runId,
@@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
null; null;
const summary = summaryFromEvents || summaryFromPayload || null; const summary = summaryFromEvents || summaryFromPayload || null;
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); const acceptedResult = asRecord(acceptedPayload?.result);
const agentMeta = asRecord(meta?.agentMeta); const latestPayload = asRecord(latestResultPayload);
const usage = parseUsage(agentMeta?.usage ?? meta?.usage); const latestResult = asRecord(latestPayload?.result);
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); const mergedMeta = {
...(acceptedMeta ?? {}),
...(latestMeta ?? {}),
};
const agentMeta =
asRecord(mergedMeta.agentMeta) ??
asRecord(acceptedMeta?.agentMeta) ??
asRecord(latestMeta?.agentMeta);
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
await ctx.onLog( await ctx.onLog(
"stdout", "stdout",
@@ -1209,6 +1364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(usage ? { usage } : {}), ...(usage ? { usage } : {}),
...(costUsd > 0 ? { costUsd } : {}), ...(costUsd > 0 ? { costUsd } : {}),
resultJson: asRecord(latestResultPayload), resultJson: asRecord(latestResultPayload),
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
...(summary ? { summary } : {}), ...(summary ? { summary } : {}),
}; };
} catch (err) { } catch (err) {

View File

@@ -1,5 +1,17 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils"; import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> { export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {}; const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url; if (v.url) ac.url = v.url;
@@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
ac.sessionKeyStrategy = "issue"; ac.sessionKeyStrategy = "issue";
ac.role = "operator"; ac.role = "operator";
ac.scopes = ["operator.admin"]; ac.scopes = ["operator.admin"];
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
return ac; return ac;
} }

View File

@@ -0,0 +1,39 @@
CREATE TABLE "workspace_runtime_services" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid,
"project_workspace_id" uuid,
"issue_id" uuid,
"scope_type" text NOT NULL,
"scope_id" text,
"service_name" text NOT NULL,
"status" text NOT NULL,
"lifecycle" text NOT NULL,
"reuse_key" text,
"command" text,
"cwd" text,
"port" integer,
"url" text,
"provider" text NOT NULL,
"provider_ref" text,
"owner_agent_id" uuid,
"started_by_run_id" uuid,
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"stopped_at" timestamp with time zone,
"stop_policy" jsonb,
"health_status" text DEFAULT 'unknown' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,20 @@
"when": 1772807461603, "when": 1772807461603,
"tag": "0025_nasty_salo", "tag": "0025_nasty_salo",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1773089625430,
"tag": "0026_lying_pete_wisdom",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
} }
] ]
} }

View File

@@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js"; export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js"; export { projects } from "./projects.js";
export { projectWorkspaces } from "./project_workspaces.js"; export { projectWorkspaces } from "./project_workspaces.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js"; export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js"; export { goals } from "./goals.js";
export { issues } from "./issues.js"; export { issues } from "./issues.js";

View File

@@ -40,6 +40,7 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0), requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"), billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(), assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }), startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }), cancelledAt: timestamp("cancelled_at", { withTimezone: true }),

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core"; import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js"; import { companies } from "./companies.js";
import { goals } from "./goals.js"; import { goals } from "./goals.js";
import { agents } from "./agents.js"; import { agents } from "./agents.js";
@@ -15,6 +15,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id), leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"), targetDate: date("target_date"),
color: text("color"), color: text("color"),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
archivedAt: timestamp("archived_at", { withTimezone: true }), archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -0,0 +1,64 @@
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const workspaceRuntimeServices = pgTable(
"workspace_runtime_services",
{
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
scopeType: text("scope_type").notNull(),
scopeId: text("scope_id"),
serviceName: text("service_name").notNull(),
status: text("status").notNull(),
lifecycle: text("lifecycle").notNull(),
reuseKey: text("reuse_key"),
command: text("command"),
cwd: text("cwd"),
port: integer("port"),
url: text("url"),
provider: text("provider").notNull(),
providerRef: text("provider_ref"),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
stopPolicy: jsonb("stop_policy").$type<Record<string, unknown>>(),
healthStatus: text("health_status").notNull().default("unknown"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on(
table.companyId,
table.projectWorkspaceId,
table.status,
),
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
table.companyId,
table.projectId,
table.status,
),
runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId),
companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on(
table.companyId,
table.updatedAt,
),
}),
);

View File

@@ -77,6 +77,12 @@ export type {
Project, Project,
ProjectGoalRef, ProjectGoalRef,
ProjectWorkspace, ProjectWorkspace,
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
Issue, Issue,
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,
IssueComment, IssueComment,
@@ -156,9 +162,11 @@ export {
type UpdateProject, type UpdateProject,
type CreateProjectWorkspace, type CreateProjectWorkspace,
type UpdateProjectWorkspace, type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,
linkIssueApprovalSchema, linkIssueApprovalSchema,

View File

@@ -11,6 +11,14 @@ export type {
} from "./agent.js"; } from "./agent.js";
export type { AssetImage } from "./asset.js"; export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
export type { export type {
Issue, Issue,
IssueAssigneeAdapterOverrides, IssueAssigneeAdapterOverrides,

View File

@@ -1,6 +1,7 @@
import type { IssuePriority, IssueStatus } from "../constants.js"; import type { IssuePriority, IssueStatus } from "../constants.js";
import type { Goal } from "./goal.js"; import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js"; import type { Project, ProjectWorkspace } from "./project.js";
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
export interface IssueAncestorProject { export interface IssueAncestorProject {
id: string; id: string;
@@ -73,6 +74,7 @@ export interface Issue {
requestDepth: number; requestDepth: number;
billingCode: string | null; billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null; assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
startedAt: Date | null; startedAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
cancelledAt: Date | null; cancelledAt: Date | null;

View File

@@ -1,4 +1,5 @@
import type { ProjectStatus } from "../constants.js"; import type { ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
export interface ProjectGoalRef { export interface ProjectGoalRef {
id: string; id: string;
@@ -15,6 +16,7 @@ export interface ProjectWorkspace {
repoRef: string | null; repoRef: string | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
isPrimary: boolean; isPrimary: boolean;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -33,6 +35,7 @@ export interface Project {
leadAgentId: string | null; leadAgentId: string | null;
targetDate: string | null; targetDate: string | null;
color: string | null; color: string | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[]; workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null; primaryWorkspace: ProjectWorkspace | null;
archivedAt: Date | null; archivedAt: Date | null;

View File

@@ -0,0 +1,58 @@
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
baseRef?: string | null;
branchTemplate?: string | null;
worktreeParentDir?: string | null;
provisionCommand?: string | null;
teardownCommand?: string | null;
}
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
defaultMode?: "project_primary" | "isolated";
allowIssueOverride?: boolean;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
branchPolicy?: Record<string, unknown> | null;
pullRequestPolicy?: Record<string, unknown> | null;
cleanupPolicy?: Record<string, unknown> | null;
}
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
export interface WorkspaceRuntimeService {
id: string;
companyId: string;
projectId: string | null;
projectWorkspaceId: string | null;
issueId: string | null;
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null;
serviceName: string;
status: "starting" | "running" | "stopped" | "failed";
lifecycle: "shared" | "ephemeral";
reuseKey: string | null;
command: string | null;
cwd: string | null;
port: number | null;
url: string | null;
provider: "local_process" | "adapter_managed";
providerRef: string | null;
ownerAgentId: string | null;
startedByRunId: string | null;
lastUsedAt: Date;
startedAt: Date;
stoppedAt: Date | null;
stopPolicy: Record<string, unknown> | null;
healthStatus: "unknown" | "healthy" | "unhealthy";
createdAt: Date;
updatedAt: Date;
}

View File

@@ -49,16 +49,19 @@ export {
updateProjectSchema, updateProjectSchema,
createProjectWorkspaceSchema, createProjectWorkspaceSchema,
updateProjectWorkspaceSchema, updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
type CreateProject, type CreateProject,
type UpdateProject, type UpdateProject,
type CreateProjectWorkspace, type CreateProjectWorkspace,
type UpdateProjectWorkspace, type UpdateProjectWorkspace,
type ProjectExecutionWorkspacePolicy,
} from "./project.js"; } from "./project.js";
export { export {
createIssueSchema, createIssueSchema,
createIssueLabelSchema, createIssueLabelSchema,
updateIssueSchema, updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
addIssueCommentSchema, addIssueCommentSchema,
linkIssueApprovalSchema, linkIssueApprovalSchema,
@@ -66,6 +69,7 @@ export {
type CreateIssue, type CreateIssue,
type CreateIssueLabel, type CreateIssueLabel,
type UpdateIssue, type UpdateIssue,
type IssueExecutionWorkspaceSettings,
type CheckoutIssue, type CheckoutIssue,
type AddIssueComment, type AddIssueComment,
type LinkIssueApproval, type LinkIssueApproval,

View File

@@ -1,6 +1,25 @@
import { z } from "zod"; import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
})
.strict();
export const issueAssigneeAdapterOverridesSchema = z export const issueAssigneeAdapterOverridesSchema = z
.object({ .object({
adapterConfig: z.record(z.unknown()).optional(), adapterConfig: z.record(z.unknown()).optional(),
@@ -21,6 +40,7 @@ export const createIssueSchema = z.object({
requestDepth: z.number().int().nonnegative().optional().default(0), requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(), billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(), assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(), labelIds: z.array(z.string().uuid()).optional(),
}); });
@@ -39,6 +59,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
}); });
export type UpdateIssue = z.infer<typeof updateIssueSchema>; export type UpdateIssue = z.infer<typeof updateIssueSchema>;
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
export const checkoutIssueSchema = z.object({ export const checkoutIssueSchema = z.object({
agentId: z.string().uuid(), agentId: z.string().uuid(),

View File

@@ -1,6 +1,30 @@
import { z } from "zod"; import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js"; import { PROJECT_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();
export const projectExecutionWorkspacePolicySchema = z
.object({
enabled: z.boolean(),
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
allowIssueOverride: z.boolean().optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(),
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
})
.strict();
const projectWorkspaceFields = { const projectWorkspaceFields = {
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
cwd: z.string().min(1).optional().nullable(), cwd: z.string().min(1).optional().nullable(),
@@ -43,6 +67,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(), leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(), targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(), color: z.string().optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(), archivedAt: z.string().datetime().optional().nullable(),
}; };
@@ -56,3 +81,5 @@ export type CreateProject = z.infer<typeof createProjectSchema>;
export const updateProjectSchema = z.object(projectFields).partial(); export const updateProjectSchema = z.object(projectFields).partial();
export type UpdateProject = z.infer<typeof updateProjectSchema>; export type UpdateProject = z.infer<typeof updateProjectSchema>;
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
if [[ ! -d "$base_cwd" ]]; then
echo "Base workspace does not exist: $base_cwd" >&2
exit 1
fi
if [[ ! -d "$worktree_cwd" ]]; then
echo "Derived worktree does not exist: $worktree_cwd" >&2
exit 1
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
source_path="$base_cwd/$relative_path"
target_path="$worktree_cwd/$relative_path"
[[ -d "$source_path" ]] || continue
[[ -e "$target_path" || -L "$target_path" ]] && continue
mkdir -p "$(dirname "$target_path")"
ln -s "$source_path" "$target_path"
done < <(
cd "$base_cwd" &&
find . \
-mindepth 1 \
-maxdepth 3 \
-type d \
-name node_modules \
! -path './.git/*' \
! -path './.paperclip/*' \
| sed 's#^\./##'
)

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "../services/execution-workspace-policy.ts";
describe("execution workspace policy helpers", () => {
it("defaults new issue settings from enabled project policy", () => {
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "isolated",
}),
).toEqual({ mode: "isolated" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
}),
).toEqual({ mode: "project_primary" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
expect(
resolveExecutionWorkspaceMode({
projectPolicy: null,
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("agent_default");
});
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
const result = buildExecutionWorkspaceAdapterConfig({
agentConfig: {
workspaceStrategy: { type: "project_primary" },
},
projectPolicy: {
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
issueSettings: null,
mode: "isolated",
legacyUseProjectWorkspace: null,
});
expect(result.workspaceStrategy).toEqual({
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
});
expect(result.workspaceRuntime).toEqual({
services: [{ name: "web", command: "pnpm dev" }],
});
});
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
const baseConfig = {
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
workspaceRuntime: { services: [{ name: "web" }] },
};
expect(
buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
legacyUseProjectWorkspace: null,
}).workspaceStrategy,
).toBeUndefined();
const agentDefault = buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: null,
issueSettings: { mode: "agent_default" },
mode: "agent_default",
legacyUseProjectWorkspace: null,
});
expect(agentDefault.workspaceStrategy).toBeUndefined();
expect(agentDefault.workspaceRuntime).toBeUndefined();
});
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
expect(
parseProjectExecutionWorkspacePolicy({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
}),
).toEqual({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
});
expect(
parseIssueExecutionWorkspaceSettings({
mode: "project_primary",
}),
).toEqual({
mode: "project_primary",
});
});
});

View File

@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server"; import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; import {
buildOpenClawGatewayConfig,
parseOpenClawGatewayStdoutLine,
} from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext( function buildContext(
@@ -36,7 +39,9 @@ function buildContext(
}; };
} }
async function createMockGatewayServer() { async function createMockGatewayServer(options?: {
waitPayload?: Record<string, unknown>;
}) {
const server = createServer(); const server = createServer();
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
type: "res", type: "res",
id: frame.id, id: frame.id,
ok: true, ok: true,
payload: { payload: options?.waitPayload ?? {
runId: frame.params?.runId, runId: frame.params?.runId,
status: "ok", status: "ok",
startedAt: 1, startedAt: 1,
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
onLog: async (_stream, chunk) => { onLog: async (_stream, chunk) => {
logs.push(chunk); logs.push(chunk);
}, },
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
paperclipWorkspace: {
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
branchName: "pap-123-test",
},
paperclipWorkspaces: [
{
id: "workspace-1",
cwd: "/tmp/project",
},
],
paperclipRuntimeServiceIntents: [
{
name: "preview",
lifecycle: "ephemeral",
},
],
},
}, },
), ),
); );
@@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => {
expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(payload?.paperclip).toEqual(
expect.objectContaining({
runId: "run-123",
companyId: "company-123",
agentId: "agent-123",
taskId: "task-123",
issueId: "issue-123",
workspace: expect.objectContaining({
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
}),
workspaces: [
expect.objectContaining({
id: "workspace-1",
cwd: "/tmp/project",
}),
],
workspaceRuntime: expect.objectContaining({
services: [
expect.objectContaining({
name: "preview",
lifecycle: "ephemeral",
}),
],
}),
}),
);
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally { } finally {
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
expect(result.errorCode).toBe("openclaw_gateway_url_missing"); expect(result.errorCode).toBe("openclaw_gateway_url_missing");
}); });
it("returns adapter-managed runtime services from gateway result meta", async () => {
const gateway = await createMockGatewayServer({
waitPayload: {
runId: "run-123",
status: "ok",
startedAt: 1,
endedAt: 2,
meta: {
runtimeServices: [
{
name: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
},
],
},
},
});
try {
const result = await execute(
buildContext({
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
waitTimeoutMs: 2000,
}),
);
expect(result.exitCode).toBe(0);
expect(result.runtimeServices).toEqual([
expect.objectContaining({
serviceName: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
status: "running",
}),
]);
} finally {
await gateway.close();
}
});
it("auto-approves pairing once and retries the run", async () => { it("auto-approves pairing once and retries the run", async () => {
const gateway = await createMockGatewayServerWithPairing(); const gateway = await createMockGatewayServerWithPairing();
const logs: string[] = []; const logs: string[] = [];
@@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => {
}); });
}); });
describe("openclaw gateway ui build config", () => {
it("parses payload template and runtime services json", () => {
const config = buildOpenClawGatewayConfig({
adapterType: "openclaw_gateway",
cwd: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "wss://gateway.example/ws",
payloadTemplateJson: JSON.stringify({
agentId: "remote-agent-123",
metadata: { team: "platform" },
}),
runtimeServicesJson: JSON.stringify({
services: [
{
name: "preview",
lifecycle: "shared",
},
],
}),
bootstrapPrompt: "",
maxTurnsPerRun: 0,
heartbeatEnabled: true,
intervalSec: 300,
});
expect(config).toEqual(
expect.objectContaining({
url: "wss://gateway.example/ws",
payloadTemplate: {
agentId: "remote-agent-123",
metadata: { team: "platform" },
},
workspaceRuntime: {
services: [
{
name: "preview",
lifecycle: "shared",
},
],
},
}),
);
});
});
describe("openclaw gateway testEnvironment", () => { describe("openclaw gateway testEnvironment", () => {
it("reports missing url as failure", async () => { it("reports missing url as failure", async () => {
const result = await testEnvironment({ const result = await testEnvironment({

View File

@@ -0,0 +1,386 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
const execFileAsync = promisify(execFile);
const leasedRunIds = new Set<string>();
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
return repoRoot;
}
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
return {
baseCwd: cwd,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
strategy: "project_primary",
cwd,
branchName: null,
worktreePath: null,
warnings: [],
created: false,
};
}
afterEach(async () => {
await Promise.all(
Array.from(leasedRunIds).map(async (runId) => {
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
}),
);
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
const first = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(first.strategy).toBe("git_worktree");
expect(first.created).toBe(true);
expect(first.branchName).toBe("PAP-447-add-worktree-support");
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
const second = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(second.created).toBe(false);
expect(second.cwd).toBe(first.cwd);
expect(second.branchName).toBe(first.branchName);
});
it("runs a configured provision command inside the derived worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe(
"PAP-448-run-provision-command\n",
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe(
`${repoRoot}\n`,
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe(
"true\n",
);
const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
});
describe("ensureRuntimeServicesForRun", () => {
it("reuses shared runtime services across runs and starts a new service after release", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
const workspace = buildWorkspace(workspaceRoot);
const serviceCommand =
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command: serviceCommand,
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 run1 = "run-1";
const run2 = "run-2";
leasedRunIds.add(run1);
leasedRunIds.add(run2);
const first = await ensureRuntimeServicesForRun({
runId: run1,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.reused).toBe(false);
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
const response = await fetch(first[0]!.url!);
expect(await response.text()).toBe("ok");
const second = await ensureRuntimeServicesForRun({
runId: run2,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.reused).toBe(true);
expect(second[0]?.id).toBe(first[0]?.id);
await releaseRuntimeServicesForRun(run1);
leasedRunIds.delete(run1);
await releaseRuntimeServicesForRun(run2);
leasedRunIds.delete(run2);
const run3 = "run-3";
leasedRunIds.add(run3);
const third = await ensureRuntimeServicesForRun({
runId: run3,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(third).toHaveLength(1);
expect(third[0]?.reused).toBe(false);
expect(third[0]?.id).not.toBe(first[0]?.id);
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
const workspace = buildWorkspace("/tmp/project");
const now = new Date("2026-03-09T12:00:00.000Z");
const first = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
const second = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
issueId: "issue-1",
serviceName: "preview",
provider: "adapter_managed",
status: "running",
healthStatus: "healthy",
startedByRunId: "run-1",
});
expect(first[0]?.id).toBe(second[0]?.id);
});
});

View File

@@ -25,7 +25,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js"; import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService } from "./services/index.js"; import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js"; import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js"; import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -495,6 +495,19 @@ export async function startServer(): Promise<StartedServer> {
deploymentMode: config.deploymentMode, deploymentMode: config.deploymentMode,
resolveSessionFromHeaders, resolveSessionFromHeaders,
}); });
void reconcilePersistedRuntimeServicesOnStartup(db as any)
.then((result) => {
if (result.reconciled > 0) {
logger.warn(
{ reconciled: result.reconciled },
"reconciled persisted runtime services from a previous server process",
);
}
})
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
});
if (config.heartbeatSchedulerEnabled) { if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any); const heartbeat = heartbeatService(db as any);
@@ -503,7 +516,7 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat.reapOrphanedRuns().catch((err) => { void heartbeat.reapOrphanedRuns().catch((err) => {
logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
}); });
setInterval(() => { setInterval(() => {
void heartbeat void heartbeat
.tickTimers(new Date()) .tickTimers(new Date())

View File

@@ -0,0 +1,143 @@
import type {
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
IssueExecutionWorkspaceSettings,
ProjectExecutionWorkspacePolicy,
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
return { ...value };
}
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
const parsed = parseObject(raw);
const type = asString(parsed.type, "");
if (type !== "project_primary" && type !== "git_worktree") {
return null;
}
return {
type,
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
};
}
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
const defaultMode = asString(parsed.defaultMode, "");
const allowIssueOverride =
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
return {
enabled,
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
: {}),
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
: {}),
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
: {}),
};
}
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const mode = asString(parsed.mode, "");
return {
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
? { mode }
: {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
};
}
export function defaultIssueExecutionWorkspaceSettingsForProject(
projectPolicy: ProjectExecutionWorkspacePolicy | null,
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
return {
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
};
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
legacyUseProjectWorkspace: boolean | null;
}): ParsedExecutionWorkspaceMode {
const issueMode = input.issueSettings?.mode;
if (issueMode && issueMode !== "inherit") {
return issueMode;
}
if (input.projectPolicy?.enabled) {
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
}
if (input.legacyUseProjectWorkspace === false) {
return "agent_default";
}
return "project_primary";
}
export function buildExecutionWorkspaceAdapterConfig(input: {
agentConfig: Record<string, unknown>;
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
mode: ParsedExecutionWorkspaceMode;
legacyUseProjectWorkspace: boolean | null;
}): Record<string, unknown> {
const nextConfig = { ...input.agentConfig };
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
const issueHasWorkspaceOverrides = Boolean(
input.issueSettings?.mode ||
input.issueSettings?.workspaceStrategy ||
input.issueSettings?.workspaceRuntime,
);
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
if (hasWorkspaceControl) {
if (input.mode === "isolated") {
const strategy =
input.issueSettings?.workspaceStrategy ??
input.projectPolicy?.workspaceStrategy ??
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
} else {
delete nextConfig.workspaceStrategy;
}
if (input.mode === "agent_default") {
delete nextConfig.workspaceRuntime;
} else if (input.issueSettings?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
} else if (input.projectPolicy?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
}
}
return nextConfig;
}

View File

@@ -11,6 +11,7 @@ import {
heartbeatRuns, heartbeatRuns,
costEvents, costEvents,
issues, issues,
projects,
projectWorkspaces, projectWorkspaces,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { conflict, notFound } from "../errors.js"; import { conflict, notFound } from "../errors.js";
@@ -23,6 +24,20 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { secretService } from "./secrets.js"; import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
buildWorkspaceReadyComment,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import {
buildExecutionWorkspaceAdapterConfig,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -406,6 +421,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) { export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore(); const runLogStore = getRunLogStore();
const secretsSvc = secretService(db); const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
async function getAgent(agentId: string) { async function getAgent(agentId: string) {
return db return db
@@ -1071,8 +1087,10 @@ export function heartbeatService(db: Db) {
const issueAssigneeConfig = issueId const issueAssigneeConfig = issueId
? await db ? await db
.select({ .select({
projectId: issues.projectId,
assigneeAgentId: issues.assigneeAgentId, assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides, assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
}) })
.from(issues) .from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
@@ -1084,6 +1102,18 @@ export function heartbeatService(db: Db) {
issueAssigneeConfig.assigneeAdapterOverrides, issueAssigneeConfig.assigneeAdapterOverrides,
) )
: null; : null;
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
issueAssigneeConfig?.executionWorkspaceSettings,
);
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
: null;
const taskSession = taskKey const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null; : null;
@@ -1093,20 +1123,72 @@ export function heartbeatService(db: Db) {
const previousSessionParams = normalizeSessionParams( const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
); );
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const resolvedWorkspace = await resolveWorkspaceForRun( const resolvedWorkspace = await resolveWorkspaceForRun(
agent, agent,
context, context,
previousSessionParams, previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null }, { useProjectWorkspace: executionWorkspaceMode !== "agent_default" },
); );
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
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 issueRef = issueId
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const executionWorkspace = await realizeExecutionWorkspace({
base: {
baseCwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
});
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({ const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id, agentId: agent.id,
previousSessionParams, previousSessionParams,
resolvedWorkspace, resolvedWorkspace: {
...resolvedWorkspace,
cwd: executionWorkspace.cwd,
},
}); });
const runtimeSessionParams = runtimeSessionResolution.sessionParams; const runtimeSessionParams = runtimeSessionResolution.sessionParams;
const runtimeWorkspaceWarnings = [ const runtimeWorkspaceWarnings = [
...resolvedWorkspace.warnings, ...resolvedWorkspace.warnings,
...executionWorkspace.warnings,
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []), ...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
...(resetTaskSession && sessionResetReason ...(resetTaskSession && sessionResetReason
? [ ? [
@@ -1117,16 +1199,33 @@ export function heartbeatService(db: Db) {
: []), : []),
]; ];
context.paperclipWorkspace = { context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd, cwd: executionWorkspace.cwd,
source: resolvedWorkspace.source, source: executionWorkspace.source,
projectId: resolvedWorkspace.projectId, mode: executionWorkspaceMode,
workspaceId: resolvedWorkspace.workspaceId, strategy: executionWorkspace.strategy,
repoUrl: resolvedWorkspace.repoUrl, projectId: executionWorkspace.projectId,
repoRef: resolvedWorkspace.repoRef, workspaceId: executionWorkspace.workspaceId,
repoUrl: executionWorkspace.repoUrl,
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
}; };
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) { const runtimeServiceIntents = (() => {
context.projectId = resolvedWorkspace.projectId; const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime);
return Array.isArray(runtimeConfig.services)
? runtimeConfig.services.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
})();
if (runtimeServiceIntents.length > 0) {
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
} else {
delete context.paperclipRuntimeServiceIntents;
}
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = executionWorkspace.projectId;
} }
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId( const previousSessionDisplayId = truncateDisplayId(
@@ -1146,7 +1245,6 @@ export function heartbeatService(db: Db) {
let handle: RunLogHandle | null = null; let handle: RunLogHandle | null = null;
let stdoutExcerpt = ""; let stdoutExcerpt = "";
let stderrExcerpt = ""; let stderrExcerpt = "";
try { try {
const startedAt = run.startedAt ?? new Date(); const startedAt = run.startedAt ?? new Date();
const runningWithSession = await db const runningWithSession = await db
@@ -1154,6 +1252,7 @@ export function heartbeatService(db: Db) {
.set({ .set({
startedAt, startedAt,
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId, sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
contextSnapshot: context,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(heartbeatRuns.id, run.id)) .where(eq(heartbeatRuns.id, run.id))
@@ -1235,15 +1334,54 @@ export function heartbeatService(db: Db) {
for (const warning of runtimeWorkspaceWarnings) { for (const warning of runtimeWorkspaceWarnings) {
await onLog("stderr", `[paperclip] ${warning}\n`); await onLog("stderr", `[paperclip] ${warning}\n`);
} }
const adapterEnv = Object.fromEntries(
const config = parseObject(agent.adapterConfig); Object.entries(parseObject(resolvedConfig.env)).filter(
const mergedConfig = issueAssigneeOverrides?.adapterConfig (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
? { ...config, ...issueAssigneeOverrides.adapterConfig } ),
: config;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
); );
const runtimeServices = await ensureRuntimeServicesForRun({
db,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
config: resolvedConfig,
adapterEnv,
onLog,
});
if (runtimeServices.length > 0) {
context.paperclipRuntimeServices = runtimeServices;
context.paperclipRuntimePrimaryUrl =
runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
}
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
const onAdapterMeta = async (meta: AdapterInvocationMeta) => { const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
if (meta.env && secretKeys.size > 0) { if (meta.env && secretKeys.size > 0) {
for (const key of secretKeys) { for (const key of secretKeys) {
@@ -1284,6 +1422,54 @@ export function heartbeatService(db: Db) {
onMeta: onAdapterMeta, onMeta: onAdapterMeta,
authToken: authToken ?? undefined, authToken: authToken ?? undefined,
}); });
const adapterManagedRuntimeServices = adapterResult.runtimeServices
? await persistAdapterManagedRuntimeServices({
db,
adapterType: agent.adapterType,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
reports: adapterResult.runtimeServices,
})
: [];
if (adapterManagedRuntimeServices.length > 0) {
const combinedRuntimeServices = [
...runtimeServices,
...adapterManagedRuntimeServices,
];
context.paperclipRuntimeServices = combinedRuntimeServices;
context.paperclipRuntimePrimaryUrl =
combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
if (issueId) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
const nextSessionState = resolveNextSessionState({ const nextSessionState = resolveNextSessionState({
codec: sessionCodec, codec: sessionCodec,
adapterResult, adapterResult,
@@ -1460,6 +1646,7 @@ export function heartbeatService(db: Db) {
await finalizeAgentStatus(agent.id, "failed"); await finalizeAgentStatus(agent.id, "failed");
} finally { } finally {
await releaseRuntimeServicesForRun(run.id);
await startNextQueuedRunForAgent(agent.id); await startNextQueuedRunForAgent(agent.id);
} }
} }

View File

@@ -17,4 +17,5 @@ export { companyPortabilityService } from "./company-portability.js";
export { logActivity, type LogActivityInput } from "./activity-log.js"; export { logActivity, type LogActivityInput } from "./activity-log.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";

View File

@@ -18,6 +18,10 @@ import {
} from "@paperclipai/db"; } from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared"; import { extractProjectMentionIds } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js"; import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -637,6 +641,19 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee"); throw unprocessable("in_progress issues require an assignee");
} }
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
const project = await tx
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
executionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
) as Record<string, unknown> | null;
}
const [company] = await tx const [company] = await tx
.update(companies) .update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` }) .set({ issueCounter: sql`${companies.issueCounter} + 1` })
@@ -646,7 +663,13 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter; const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`; const identifier = `${company.issuePrefix}-${issueNumber}`;
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert; const values = {
...issueData,
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
identifier,
} as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) { if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date(); values.startedAt = new Date();
} }

View File

@@ -1,17 +1,22 @@
import { and, asc, desc, eq, inArray } from "drizzle-orm"; import { and, asc, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclipai/db"; import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import { import {
PROJECT_COLORS, PROJECT_COLORS,
deriveProjectUrlKey, deriveProjectUrlKey,
isUuidLike, isUuidLike,
normalizeProjectUrlKey, normalizeProjectUrlKey,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef, type ProjectGoalRef,
type ProjectWorkspace, type ProjectWorkspace,
type WorkspaceRuntimeService,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
type ProjectRow = typeof projects.$inferSelect; type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
type CreateWorkspaceInput = { type CreateWorkspaceInput = {
name?: string | null; name?: string | null;
@@ -23,10 +28,11 @@ type CreateWorkspaceInput = {
}; };
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>; type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
interface ProjectWithGoals extends ProjectRow { interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
urlKey: string; urlKey: string;
goalIds: string[]; goalIds: string[];
goals: ProjectGoalRef[]; goals: ProjectGoalRef[];
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[]; workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null; primaryWorkspace: ProjectWorkspace | null;
} }
@@ -74,11 +80,46 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
urlKey: deriveProjectUrlKey(r.name, r.id), urlKey: deriveProjectUrlKey(r.name, r.id),
goalIds: g.map((x) => x.id), goalIds: g.map((x) => x.id),
goals: g, goals: g,
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
} as ProjectWithGoals; } as ProjectWithGoals;
}); });
} }
function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace { function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? 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 toWorkspace(
row: ProjectWorkspaceRow,
runtimeServices: WorkspaceRuntimeService[] = [],
): ProjectWorkspace {
return { return {
id: row.id, id: row.id,
companyId: row.companyId, companyId: row.companyId,
@@ -89,15 +130,20 @@ function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
repoRef: row.repoRef ?? null, repoRef: row.repoRef ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null, metadata: (row.metadata as Record<string, unknown> | null) ?? null,
isPrimary: row.isPrimary, isPrimary: row.isPrimary,
runtimeServices,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
} }
function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null { function pickPrimaryWorkspace(
rows: ProjectWorkspaceRow[],
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
): ProjectWorkspace | null {
if (rows.length === 0) return null; if (rows.length === 0) return null;
const explicitPrimary = rows.find((row) => row.isPrimary); const explicitPrimary = rows.find((row) => row.isPrimary);
return toWorkspace(explicitPrimary ?? rows[0]); const primary = explicitPrimary ?? rows[0];
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
} }
/** Batch-load workspace refs for a set of projects. */ /** Batch-load workspace refs for a set of projects. */
@@ -110,6 +156,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
.from(projectWorkspaces) .from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds)) .where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
db,
rows[0]!.companyId,
workspaceRows.map((workspace) => workspace.id),
);
const sharedRuntimeServicesByWorkspaceId = new Map(
Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [
workspaceId,
services.map(toRuntimeService),
]),
);
const map = new Map<string, ProjectWorkspaceRow[]>(); const map = new Map<string, ProjectWorkspaceRow[]>();
for (const row of workspaceRows) { for (const row of workspaceRows) {
@@ -123,11 +180,16 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
return rows.map((row) => { return rows.map((row) => {
const projectWorkspaceRows = map.get(row.id) ?? []; const projectWorkspaceRows = map.get(row.id) ?? [];
const workspaces = projectWorkspaceRows.map(toWorkspace); const workspaces = projectWorkspaceRows.map((workspace) =>
toWorkspace(
workspace,
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
),
);
return { return {
...row, ...row,
workspaces, workspaces,
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows), primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
}; };
}); });
} }
@@ -402,7 +464,18 @@ export function projectService(db: Db) {
.from(projectWorkspaces) .from(projectWorkspaces)
.where(eq(projectWorkspaces.projectId, projectId)) .where(eq(projectWorkspaces.projectId, projectId))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
return rows.map(toWorkspace); if (rows.length === 0) return [];
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
db,
rows[0]!.companyId,
rows.map((workspace) => workspace.id),
);
return rows.map((row) =>
toWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
),
);
}, },
createWorkspace: async ( createWorkspace: async (

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,7 @@ function boardRoutes() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} /> <Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} /> <Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} /> <Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} /> <Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} /> <Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} /> <Route path="issues/active" element={<Navigate to="/issues" replace />} />
@@ -235,6 +236,7 @@ export function App() {
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} /> <Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}> <Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()} {boardRoutes()}
</Route> </Route>

View File

@@ -7,6 +7,7 @@ import {
help, help,
} from "../../components/agent-config-primitives"; } from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal"; import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass = const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -15,38 +16,54 @@ const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function ClaudeLocalConfigFields({ export function ClaudeLocalConfigFields({
mode,
isCreate, isCreate,
adapterType,
values, values,
set, set,
config, config,
eff, eff,
mark, mark,
models,
}: AdapterConfigFieldsProps) { }: AdapterConfigFieldsProps) {
return ( return (
<Field label="Agent instructions file" hint={instructionsFileHint}> <>
<div className="flex items-center gap-2"> <Field label="Agent instructions file" hint={instructionsFileHint}>
<DraftInput <div className="flex items-center gap-2">
value={ <DraftInput
isCreate value={
? values!.instructionsFilePath ?? "" isCreate
: eff( ? values!.instructionsFilePath ?? ""
"adapterConfig", : eff(
"instructionsFilePath", "adapterConfig",
String(config.instructionsFilePath ?? ""), "instructionsFilePath",
) String(config.instructionsFilePath ?? ""),
} )
onCommit={(v) => }
isCreate onCommit={(v) =>
? set!({ instructionsFilePath: v }) isCreate
: mark("adapterConfig", "instructionsFilePath", v || undefined) ? set!({ instructionsFilePath: v })
} : mark("adapterConfig", "instructionsFilePath", v || undefined)
immediate }
className={inputClass} immediate
placeholder="/absolute/path/to/AGENTS.md" className={inputClass}
/> placeholder="/absolute/path/to/AGENTS.md"
<ChoosePathButton /> />
</div> <ChoosePathButton />
</Field> </div>
</Field>
<LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
eff={eff}
mode={mode}
adapterType={adapterType}
models={models}
/>
</>
); );
} }

View File

@@ -6,6 +6,7 @@ import {
help, help,
} from "../../components/agent-config-primitives"; } from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal"; import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass = const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -13,12 +14,15 @@ const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function CodexLocalConfigFields({ export function CodexLocalConfigFields({
mode,
isCreate, isCreate,
adapterType,
values, values,
set, set,
config, config,
eff, eff,
mark, mark,
models,
}: AdapterConfigFieldsProps) { }: AdapterConfigFieldsProps) {
const bypassEnabled = const bypassEnabled =
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true; config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
@@ -81,6 +85,17 @@ export function CodexLocalConfigFields({
: mark("adapterConfig", "search", v) : mark("adapterConfig", "search", v)
} }
/> />
<LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
eff={eff}
mode={mode}
adapterType={adapterType}
models={models}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,5 @@
import type { AdapterConfigFieldsProps } from "./types";
export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) {
return null;
}

View File

@@ -6,6 +6,10 @@ import {
DraftInput, DraftInput,
help, help,
} from "../../components/agent-config-primitives"; } from "../../components/agent-config-primitives";
import {
PayloadTemplateJsonField,
RuntimeServicesJsonField,
} from "../runtime-json-fields";
const inputClass = const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@@ -112,6 +116,22 @@ export function OpenClawGatewayConfigFields({
/> />
</Field> </Field>
<PayloadTemplateJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
<RuntimeServicesJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
{!isCreate && ( {!isCreate && (
<> <>
<Field label="Paperclip API URL override"> <Field label="Paperclip API URL override">

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import type { AdapterConfigFieldsProps } from "./types";
import { Field, help } from "../components/agent-config-primitives";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function formatJsonObject(value: unknown): string {
const record = asRecord(value);
return Object.keys(record).length > 0 ? JSON.stringify(record, null, 2) : "";
}
function updateJsonConfig(
isCreate: boolean,
key: "runtimeServicesJson" | "payloadTemplateJson",
next: string,
set: AdapterConfigFieldsProps["set"],
mark: AdapterConfigFieldsProps["mark"],
configKey: string,
) {
if (isCreate) {
set?.({ [key]: next });
return;
}
const trimmed = next.trim();
if (!trimmed) {
mark("adapterConfig", configKey, undefined);
return;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
mark("adapterConfig", configKey, parsed);
}
} catch {
// Keep local draft until JSON is valid.
}
}
type JsonFieldProps = Pick<
AdapterConfigFieldsProps,
"isCreate" | "values" | "set" | "config" | "mark"
>;
export function RuntimeServicesJsonField({
isCreate,
values,
set,
config,
mark,
}: JsonFieldProps) {
if (!SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI) {
return null;
}
const existing = formatJsonObject(config.workspaceRuntime);
const [draft, setDraft] = useState(existing);
useEffect(() => {
if (!isCreate) setDraft(existing);
}, [existing, isCreate]);
const value = isCreate ? values?.runtimeServicesJson ?? "" : draft;
return (
<Field label="Runtime services JSON" hint={help.runtimeServicesJson}>
<textarea
className={`${inputClass} min-h-[148px]`}
value={value}
onChange={(e) => {
const next = e.target.value;
if (!isCreate) setDraft(next);
updateJsonConfig(isCreate, "runtimeServicesJson", next, set, mark, "workspaceRuntime");
}}
placeholder={`{\n "services": [\n {\n "name": "preview",\n "lifecycle": "ephemeral",\n "metadata": {\n "purpose": "remote preview"\n }\n }\n ]\n}`}
/>
</Field>
);
}
export function PayloadTemplateJsonField({
isCreate,
values,
set,
config,
mark,
}: JsonFieldProps) {
const existing = formatJsonObject(config.payloadTemplate);
const [draft, setDraft] = useState(existing);
useEffect(() => {
if (!isCreate) setDraft(existing);
}, [existing, isCreate]);
const value = isCreate ? values?.payloadTemplateJson ?? "" : draft;
return (
<Field label="Payload template JSON" hint={help.payloadTemplateJson}>
<textarea
className={`${inputClass} min-h-[132px]`}
value={value}
onChange={(e) => {
const next = e.target.value;
if (!isCreate) setDraft(next);
updateJsonConfig(isCreate, "payloadTemplateJson", next, set, mark, "payloadTemplate");
}}
placeholder={`{\n "agentId": "remote-agent-123",\n "metadata": {\n "team": "platform"\n }\n}`}
/>
</Field>
);
}

View File

@@ -20,6 +20,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react"; import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
interface IssuePropertiesProps { interface IssuePropertiesProps {
issue: Issue; issue: Issue;
onUpdate: (data: Record<string, unknown>) => void; onUpdate: (data: Record<string, unknown>) => void;
@@ -176,6 +179,18 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const project = orderedProjects.find((p) => p.id === id); const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8); return project?.name ?? id.slice(0, 8);
}; };
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
? true
: issue.executionWorkspaceSettings?.mode === "project_primary"
? false
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
const projectLink = (id: string | null) => { const projectLink = (id: string | null) => {
if (!id) return null; if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null; const project = projects?.find((p) => p.id === id) ?? null;
@@ -402,7 +417,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent" !issue.projectId && "bg-accent"
)} )}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }} onClick={() => {
onUpdate({ projectId: null, executionWorkspaceSettings: null });
setProjectOpen(false);
}}
> >
No project No project
</button> </button>
@@ -419,7 +437,15 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent" p.id === issue.projectId && "bg-accent"
)} )}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }} onClick={() => {
onUpdate({
projectId: p.id,
executionWorkspaceSettings: SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && p.executionWorkspacePolicy?.enabled
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
: null,
});
setProjectOpen(false);
}}
> >
<span <span
className="shrink-0 h-3 w-3 rounded-sm" className="shrink-0 h-3 w-3 rounded-sm"
@@ -504,6 +530,42 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{projectContent} {projectContent}
</PropertyPicker> </PropertyPicker>
{currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace">
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full">
<div className="min-w-0">
<div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
</div>
<div className="text-[11px] text-muted-foreground">
Toggle whether this issue runs in its own execution workspace.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
onUpdate({
executionWorkspaceSettings: {
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
},
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</PropertyRow>
)}
{issue.parentId && ( {issue.parentId && (
<PropertyRow label="Parent"> <PropertyRow label="Parent">
<Link <Link

View File

@@ -44,6 +44,8 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft"; const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800; const DEBOUNCE_MS = 800;
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */ /** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string { function getContrastTextColor(hexColor: string): string {
@@ -65,7 +67,7 @@ interface IssueDraft {
assigneeModelOverride: string; assigneeModelOverride: string;
assigneeThinkingEffort: string; assigneeThinkingEffort: string;
assigneeChrome: boolean; assigneeChrome: boolean;
assigneeUseProjectWorkspace: boolean; useIsolatedExecutionWorkspace: boolean;
} }
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
@@ -99,7 +101,6 @@ function buildAssigneeAdapterOverrides(input: {
modelOverride: string; modelOverride: string;
thinkingEffortOverride: string; thinkingEffortOverride: string;
chrome: boolean; chrome: boolean;
useProjectWorkspace: boolean;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null; const adapterType = input.adapterType ?? null;
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
@@ -127,9 +128,6 @@ function buildAssigneeAdapterOverrides(input: {
if (Object.keys(adapterConfig).length > 0) { if (Object.keys(adapterConfig).length > 0) {
overrides.adapterConfig = adapterConfig; overrides.adapterConfig = adapterConfig;
} }
if (!input.useProjectWorkspace) {
overrides.useProjectWorkspace = false;
}
return Object.keys(overrides).length > 0 ? overrides : null; return Object.keys(overrides).length > 0 ? overrides : null;
} }
@@ -180,10 +178,11 @@ export function NewIssueDialog() {
const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState(""); const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false); const [assigneeChrome, setAssigneeChrome] = useState(false);
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true); const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null); const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId; const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany; const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
@@ -300,7 +299,7 @@ export function NewIssueDialog() {
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
assigneeChrome, assigneeChrome,
assigneeUseProjectWorkspace, useIsolatedExecutionWorkspace,
}); });
}, [ }, [
title, title,
@@ -312,7 +311,7 @@ export function NewIssueDialog() {
assigneeModelOverride, assigneeModelOverride,
assigneeThinkingEffort, assigneeThinkingEffort,
assigneeChrome, assigneeChrome,
assigneeUseProjectWorkspace, useIsolatedExecutionWorkspace,
newIssueOpen, newIssueOpen,
scheduleSave, scheduleSave,
]); ]);
@@ -321,6 +320,7 @@ export function NewIssueDialog() {
useEffect(() => { useEffect(() => {
if (!newIssueOpen) return; if (!newIssueOpen) return;
setDialogCompanyId(selectedCompanyId); setDialogCompanyId(selectedCompanyId);
executionWorkspaceDefaultProjectId.current = null;
const draft = loadDraft(); const draft = loadDraft();
if (newIssueDefaults.title) { if (newIssueDefaults.title) {
@@ -333,7 +333,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setUseIsolatedExecutionWorkspace(false);
} else if (draft && draft.title.trim()) { } else if (draft && draft.title.trim()) {
setTitle(draft.title); setTitle(draft.title);
setDescription(draft.description); setDescription(draft.description);
@@ -344,7 +344,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false); setAssigneeChrome(draft.assigneeChrome ?? false);
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true); setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
} else { } else {
setStatus(newIssueDefaults.status ?? "todo"); setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? ""); setPriority(newIssueDefaults.priority ?? "");
@@ -353,7 +353,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setUseIsolatedExecutionWorkspace(false);
} }
}, [newIssueOpen, newIssueDefaults]); }, [newIssueOpen, newIssueDefaults]);
@@ -363,7 +363,6 @@ export function NewIssueDialog() {
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
return; return;
} }
@@ -396,10 +395,11 @@ export function NewIssueDialog() {
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setUseIsolatedExecutionWorkspace(false);
setExpanded(false); setExpanded(false);
setDialogCompanyId(null); setDialogCompanyId(null);
setCompanyOpen(false); setCompanyOpen(false);
executionWorkspaceDefaultProjectId.current = null;
} }
function handleCompanyChange(companyId: string) { function handleCompanyChange(companyId: string) {
@@ -410,7 +410,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride(""); setAssigneeModelOverride("");
setAssigneeThinkingEffort(""); setAssigneeThinkingEffort("");
setAssigneeChrome(false); setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true); setUseIsolatedExecutionWorkspace(false);
} }
function discardDraft() { function discardDraft() {
@@ -426,8 +426,16 @@ export function NewIssueDialog() {
modelOverride: assigneeModelOverride, modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort, thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome, chrome: assigneeChrome,
useProjectWorkspace: assigneeUseProjectWorkspace,
}); });
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? selectedProject?.executionWorkspacePolicy
: null;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? {
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
}
: null;
createIssue.mutate({ createIssue.mutate({
companyId: effectiveCompanyId, companyId: effectiveCompanyId,
title: title.trim(), title: title.trim(),
@@ -437,6 +445,7 @@ export function NewIssueDialog() {
...(assigneeId ? { assigneeAgentId: assigneeId } : {}), ...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(projectId ? { projectId } : {}), ...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}), ...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
}); });
} }
@@ -467,6 +476,10 @@ export function NewIssueDialog() {
const currentPriority = priorities.find((p) => p.value === priority); const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentProject = orderedProjects.find((project) => project.id === projectId); const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const assigneeOptionsTitle = const assigneeOptionsTitle =
assigneeAdapterType === "claude_local" assigneeAdapterType === "claude_local"
? "Claude options" ? "Claude options"
@@ -503,6 +516,30 @@ export function NewIssueDialog() {
})), })),
[orderedProjects], [orderedProjects],
); );
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
const policy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? nextProject?.executionWorkspacePolicy : null;
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
}, [orderedProjects]);
useEffect(() => {
if (!newIssueOpen || !projectId || executionWorkspaceDefaultProjectId.current === projectId) {
return;
}
const project = orderedProjects.find((entry) => entry.id === projectId);
if (!project) return;
executionWorkspaceDefaultProjectId.current = projectId;
setUseIsolatedExecutionWorkspace(
Boolean(
SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI &&
project.executionWorkspacePolicy?.enabled &&
project.executionWorkspacePolicy.defaultMode === "isolated",
),
);
}, [newIssueOpen, orderedProjects, projectId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>( const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() => { () => {
return [...(assigneeAdapterModels ?? [])] return [...(assigneeAdapterModels ?? [])]
@@ -705,7 +742,7 @@ export function NewIssueDialog() {
noneLabel="No project" noneLabel="No project"
searchPlaceholder="Search projects..." searchPlaceholder="Search projects..."
emptyMessage="No projects found." emptyMessage="No projects found."
onChange={setProjectId} onChange={handleProjectChange}
onConfirm={() => { onConfirm={() => {
descriptionEditorRef.current?.focus(); descriptionEditorRef.current?.focus();
}} }}
@@ -740,6 +777,34 @@ export function NewIssueDialog() {
</div> </div>
</div> </div>
{currentProjectSupportsExecutionWorkspace && (
<div className="px-4 pb-2 shrink-0">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div className="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground">
Create an issue-specific execution workspace instead of using the project's primary checkout.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
type="button"
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</div>
)}
{supportsAssigneeOverrides && ( {supportsAssigneeOverrides && (
<div className="px-4 pb-2 shrink-0"> <div className="px-4 pb-2 shrink-0">
<button <button
@@ -800,23 +865,6 @@ export function NewIssueDialog() {
</button> </button>
</div> </div>
)} )}
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Use project workspace</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -11,9 +11,10 @@ interface PageTabBarProps {
items: PageTabItem[]; items: PageTabItem[];
value?: string; value?: string;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
align?: "center" | "start";
} }
export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) { export function PageTabBar({ items, value, onValueChange, align = "center" }: PageTabBarProps) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
if (isMobile && value !== undefined && onValueChange) { if (isMobile && value !== undefined && onValueChange) {
@@ -33,7 +34,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
} }
return ( return (
<TabsList variant="line"> <TabsList variant="line" className={align === "start" ? "justify-start" : undefined}>
{items.map((item) => ( {items.map((item) => (
<TabsTrigger key={item.value} value={item.value}> <TabsTrigger key={item.value} value={item.value}>
{item.label} {item.label}

View File

@@ -13,8 +13,10 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react"; import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
const PROJECT_STATUSES = [ const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" }, { value: "backlog", label: "Backlog" },
@@ -24,18 +26,92 @@ const PROJECT_STATUSES = [
{ value: "cancelled", label: "Cancelled" }, { value: "cancelled", label: "Cancelled" },
]; ];
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
interface ProjectPropertiesProps { interface ProjectPropertiesProps {
project: Project; project: Project;
onUpdate?: (data: Record<string, unknown>) => void; onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
} }
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "goals"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_base_ref"
| "execution_workspace_branch_template"
| "execution_workspace_worktree_parent_dir"
| "execution_workspace_provision_command"
| "execution_workspace_teardown_command";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function SaveIndicator({ state }: { state: ProjectFieldSaveState }) {
if (state === "saving") {
return (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Saving
</span>
);
}
if (state === "saved") {
return (
<span className="inline-flex items-center gap-1 text-[11px] text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Saved
</span>
);
}
if (state === "error") {
return (
<span className="inline-flex items-center gap-1 text-[11px] text-destructive">
<AlertCircle className="h-3 w-3" />
Failed
</span>
);
}
return null;
}
function FieldLabel({
label,
state,
}: {
label: string;
state: ProjectFieldSaveState;
}) {
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5 min-w-0">{children}</div> <SaveIndicator state={state} />
</div>
);
}
function PropertyRow({
label,
children,
alignStart = false,
valueClassName = "",
}: {
label: React.ReactNode;
children: React.ReactNode;
alignStart?: boolean;
valueClassName?: string;
}) {
return (
<div className={cn("flex gap-3 py-1.5", alignStart ? "items-start" : "items-center")}>
<div className="shrink-0 w-20">{label}</div>
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5", valueClassName)}>
{children}
</div>
</div> </div>
); );
} }
@@ -76,15 +152,25 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
); );
} }
export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) { export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false); const [goalOpen, setGoalOpen] = useState(false);
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null); const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState(""); const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null); const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
if (onFieldUpdate) {
onFieldUpdate(field, data);
return;
}
onUpdate?.(data);
};
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
const { data: allGoals } = useQuery({ const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!), queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!), queryFn: () => goalsApi.list(selectedCompanyId!),
@@ -106,6 +192,16 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id)); const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
const workspaces = project.workspaces ?? []; const workspaces = project.workspaces ?? [];
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const executionWorkspaceDefaultMode =
executionWorkspacePolicy?.defaultMode === "isolated" ? "isolated" : "project_primary";
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
type: "git_worktree",
baseRef: "",
branchTemplate: "",
worktreeParentDir: "",
};
const invalidateProject = () => { const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
@@ -136,16 +232,29 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
}); });
const removeGoal = (goalId: string) => { const removeGoal = (goalId: string) => {
if (!onUpdate) return; if (!onUpdate && !onFieldUpdate) return;
onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) }); commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
}; };
const addGoal = (goalId: string) => { const addGoal = (goalId: string) => {
if (!onUpdate || linkedGoalIds.includes(goalId)) return; if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
onUpdate({ goalIds: [...linkedGoalIds, goalId] }); commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
setGoalOpen(false); setGoalOpen(false);
}; };
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate && !onFieldUpdate) return;
return {
executionWorkspacePolicy: {
enabled: executionWorkspacesEnabled,
defaultMode: executionWorkspaceDefaultMode,
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
...executionWorkspacePolicy,
...patch,
},
};
};
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => { const isGitHubRepoUrl = (value: string) => {
@@ -254,13 +363,46 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
}; };
return ( return (
<div className="space-y-4"> <div>
<div className="space-y-1"> <div className="space-y-1 pb-4">
<PropertyRow label="Status"> <PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
{onUpdate ? ( {onUpdate || onFieldUpdate ? (
<DraftInput
value={project.name}
onCommit={(name) => commitField("name", { name })}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none"
placeholder="Project name"
/>
) : (
<span className="text-sm">{project.name}</span>
)}
</PropertyRow>
<PropertyRow
label={<FieldLabel label="Description" state={fieldState("description")} />}
alignStart
valueClassName="space-y-0.5"
>
{onUpdate || onFieldUpdate ? (
<InlineEditor
value={project.description ?? ""}
onSave={(description) => commitField("description", { description })}
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
) : (
<p className="text-sm text-muted-foreground">
{project.description?.trim() || "No description"}
</p>
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Status" state={fieldState("status")} />}>
{onUpdate || onFieldUpdate ? (
<ProjectStatusPicker <ProjectStatusPicker
status={project.status} status={project.status}
onChange={(status) => onUpdate({ status })} onChange={(status) => commitField("status", { status })}
/> />
) : ( ) : (
<StatusBadge status={project.status} /> <StatusBadge status={project.status} />
@@ -271,82 +413,87 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span> <span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
</PropertyRow> </PropertyRow>
)} )}
<div className="py-1.5"> <PropertyRow
<div className="flex items-start justify-between gap-2"> label={<FieldLabel label="Goals" state={fieldState("goals")} />}
<span className="text-xs text-muted-foreground">Goals</span> alignStart
<div className="flex flex-col items-end gap-1.5"> valueClassName="space-y-2"
{linkedGoals.length === 0 ? ( >
<span className="text-sm text-muted-foreground">None</span> {linkedGoals.length === 0 ? (
) : ( <span className="text-sm text-muted-foreground">None</span>
<div className="flex flex-wrap justify-end gap-1.5 max-w-[220px]"> ) : (
{linkedGoals.map((goal) => ( <div className="flex flex-wrap gap-1.5">
<span {linkedGoals.map((goal) => (
key={goal.id} <span
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs" key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[220px] truncate">
{goal.title}
</Link>
{(onUpdate || onFieldUpdate) && (
<button
className="text-muted-foreground hover:text-foreground"
type="button"
onClick={() => removeGoal(goal.id)}
aria-label={`Remove goal ${goal.title}`}
> >
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[140px] truncate"> <X className="h-3 w-3" />
{goal.title} </button>
</Link> )}
{onUpdate && ( </span>
<button ))}
className="text-muted-foreground hover:text-foreground"
type="button"
onClick={() => removeGoal(goal.id)}
aria-label={`Remove goal ${goal.title}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}
{onUpdate && (
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={availableGoals.length === 0}
>
<Plus className="h-3 w-3 mr-1" />
Goal
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="end">
{availableGoals.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals linked.
</div>
) : (
availableGoals.map((goal) => (
<button
key={goal.id}
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => addGoal(goal.id)}
>
{goal.title}
</button>
))
)}
</PopoverContent>
</Popover>
)}
</div> </div>
</div> )}
</div> {(onUpdate || onFieldUpdate) && (
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="xs"
className="h-6 w-fit px-2"
disabled={availableGoals.length === 0}
>
<Plus className="h-3 w-3 mr-1" />
Goal
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{availableGoals.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals linked.
</div>
) : (
availableGoals.map((goal) => (
<button
key={goal.id}
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => addGoal(goal.id)}
>
{goal.title}
</button>
))
)}
</PopoverContent>
</Popover>
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
{project.targetDate && ( {project.targetDate && (
<PropertyRow label="Target Date"> <PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
<span className="text-sm">{formatDate(project.targetDate)}</span> <span className="text-sm">{formatDate(project.targetDate)}</span>
</PropertyRow> </PropertyRow>
)} )}
</div> </div>
<Separator /> <Separator className="my-4" />
<div className="space-y-1"> <div className="space-y-1 py-4">
<div className="py-1.5 space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Workspaces</span> <span>Workspaces</span>
<Tooltip> <Tooltip>
@@ -407,6 +554,51 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
</Button> </Button>
</div> </div>
) : null} ) : null}
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-1 pl-2">
{workspace.runtimeServices.map((service) => (
<div
key={service.id}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-1"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium">{service.serviceName}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide",
service.status === "running"
? "bg-green-500/15 text-green-700 dark:text-green-300"
: service.status === "failed"
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-muted text-muted-foreground",
)}
>
{service.status}
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{service.url ? (
<a
href={service.url}
target="_blank"
rel="noreferrer"
className="hover:text-foreground hover:underline"
>
{service.url}
</a>
) : (
service.command ?? "No URL"
)}
</div>
</div>
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{service.lifecycle}
</div>
</div>
))}
</div>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -518,14 +710,249 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
)} )}
</div> </div>
<Separator /> {SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && (
<>
<Separator className="my-4" />
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Execution Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm font-medium">
<span>Enable isolated issue checkouts</span>
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
</div>
<div className="text-xs text-muted-foreground">
Let issues choose between the projects primary checkout and an isolated execution workspace.
</div>
</div>
{onUpdate || onFieldUpdate ? (
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
</span>
)}
</div>
{executionWorkspacesEnabled && (
<>
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="flex items-center gap-2 text-sm">
<span>New issues default to isolated checkout</span>
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
</div>
<div className="text-[11px] text-muted-foreground">
If disabled, new issues stay on the projects primary checkout unless someone opts in.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
defaultMode: executionWorkspaceDefaultMode === "isolated" ? "project_primary" : "isolated",
})!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated" ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
<div className="border-t border-border/60 pt-2">
<button
type="button"
className="flex items-center gap-2 w-full py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
>
{executionWorkspaceAdvancedOpen ? "Hide advanced checkout settings" : "Show advanced checkout settings"}
</button>
</div>
{executionWorkspaceAdvancedOpen && (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Host-managed implementation: <span className="text-foreground">Git worktree</span>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Base ref</span>
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.baseRef ?? ""}
onCommit={(value) =>
commitField("execution_workspace_base_ref", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
baseRef: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="origin/main"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Branch template</span>
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.branchTemplate ?? ""}
onCommit={(value) =>
commitField("execution_workspace_branch_template", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
branchTemplate: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="{{issue.identifier}}-{{slug}}"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Worktree parent dir</span>
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
onCommit={(value) =>
commitField("execution_workspace_worktree_parent_dir", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
worktreeParentDir: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder=".paperclip/worktrees"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Provision command</span>
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.provisionCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_provision_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
provisionCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/provision-worktree.sh"
/>
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Teardown command</span>
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.teardownCommand ?? ""}
onCommit={(value) =>
commitField("execution_workspace_teardown_command", {
...updateExecutionWorkspacePolicy({
workspaceStrategy: {
...executionWorkspaceStrategy,
type: "git_worktree",
teardownCommand: value || null,
},
})!,
})}
immediate
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
future cleanup flows.
</p>
</div>
)}
</>
)}
</div>
</div>
</>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
</div> </div>
</div> </div>
); );

View File

@@ -18,6 +18,12 @@ export const defaultCreateValues: CreateConfigValues = {
envBindings: {}, envBindings: {},
url: "", url: "",
bootstrapPrompt: "", bootstrapPrompt: "",
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 80, maxTurnsPerRun: 80,
heartbeatEnabled: false, heartbeatEnabled: false,
intervalSec: 300, intervalSec: 300,

View File

@@ -33,12 +33,19 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.", search: "Enable Codex web search capability during runs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
runtimeServicesJson: "Optional workspace runtime service definitions. Use this for shared app servers, workers, or other long-lived companion processes attached to the workspace.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).", command: "The command to execute (e.g. node, python).",
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).", localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
args: "Command-line arguments, comma-separated.", args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.",
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.", webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.", heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.", intervalSec: "Seconds between automatic heartbeat invocations.",

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -14,6 +14,7 @@ import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm"; import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
import { getUIAdapter, buildTranscript } from "../adapters"; import { getUIAdapter, buildTranscript } from "../adapters";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
@@ -28,6 +29,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -53,7 +55,6 @@ import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
ArrowLeft, ArrowLeft,
Settings,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
@@ -173,12 +174,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior }); container.scrollTo({ top: container.scrollHeight, behavior });
} }
type AgentDetailView = "overview" | "configure" | "runs"; type AgentDetailView = "dashboard" | "configuration" | "runs";
function parseAgentDetailView(value: string | null): AgentDetailView { function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configure"; if (value === "configure" || value === "configuration") return "configuration";
if (value === "runs") return value; if (value === "runs") return value;
return "overview"; return "dashboard";
} }
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) { function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
@@ -304,17 +305,18 @@ export function AgentDetail() {
useEffect(() => { useEffect(() => {
if (!agent) return; if (!agent) return;
if (routeAgentRef === canonicalAgentRef) return;
if (urlRunId) { if (urlRunId) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); if (routeAgentRef !== canonicalAgentRef) {
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
}
return; return;
} }
if (urlTab) { const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true }); if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return; return;
} }
navigate(`/agents/${canonicalAgentRef}`, { replace: true }); }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
useEffect(() => { useEffect(() => {
if (!agent?.companyId || agent.companyId === selectedCompanyId) return; if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
@@ -397,17 +399,19 @@ export function AgentDetail() {
{ label: "Agents", href: "/agents" }, { label: "Agents", href: "/agents" },
]; ];
const agentName = agent?.name ?? routeAgentRef ?? "Agent"; const agentName = agent?.name ?? routeAgentRef ?? "Agent";
if (activeView === "overview" && !urlRunId) { if (activeView === "dashboard" && !urlRunId) {
crumbs.push({ label: agentName }); crumbs.push({ label: agentName });
} else { } else {
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` }); crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` });
if (urlRunId) { if (urlRunId) {
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configure") { } else if (activeView === "configuration") {
crumbs.push({ label: "Configure" }); crumbs.push({ label: "Configuration" });
} else if (activeView === "runs") { } else if (activeView === "runs") {
crumbs.push({ label: "Runs" }); crumbs.push({ label: "Runs" });
} else {
crumbs.push({ label: "Dashboard" });
} }
} }
setBreadcrumbs(crumbs); setBreadcrumbs(crumbs);
@@ -416,7 +420,7 @@ export function AgentDetail() {
useEffect(() => { useEffect(() => {
closePanel(); closePanel();
return () => closePanel(); return () => closePanel();
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [closePanel]);
useBeforeUnload( useBeforeUnload(
useCallback((event) => { useCallback((event) => {
@@ -429,8 +433,11 @@ export function AgentDetail() {
if (isLoading) return <PageSkeleton variant="detail" />; if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>; if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null; if (!agent) return null;
if (!urlRunId && !urlTab) {
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
}
const isPendingApproval = agent.status === "pending_approval"; const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeView === "configure" && configDirty; const showConfigActionBar = activeView === "configuration" && configDirty;
return ( return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}> <div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -514,16 +521,6 @@ export function AgentDetail() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end"> <PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
navigate(`/agents/${canonicalAgentRef}/configure`);
setMoreOpen(false);
}}
>
<Settings className="h-3 w-3" />
Configure Agent
</button>
<button <button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50" className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => { onClick={() => {
@@ -559,6 +556,22 @@ export function AgentDetail() {
</div> </div>
</div> </div>
{!urlRunId && (
<Tabs
value={activeView === "configuration" ? "configuration" : "dashboard"}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
]}
value={activeView === "configuration" ? "configuration" : "dashboard"}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
/>
</Tabs>
)}
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && ( {isPendingApproval && (
<p className="text-sm text-amber-500"> <p className="text-sm text-amber-500">
@@ -623,20 +636,18 @@ export function AgentDetail() {
)} )}
{/* View content */} {/* View content */}
{activeView === "overview" && ( {activeView === "dashboard" && (
<AgentOverview <AgentOverview
agent={agent} agent={agent}
runs={heartbeats ?? []} runs={heartbeats ?? []}
assignedIssues={assignedIssues} assignedIssues={assignedIssues}
runtimeState={runtimeState} runtimeState={runtimeState}
reportsToAgent={reportsToAgent ?? null}
directReports={directReports}
agentId={agent.id} agentId={agent.id}
agentRouteId={canonicalAgentRef} agentRouteId={canonicalAgentRef}
/> />
)} )}
{activeView === "configure" && ( {activeView === "configuration" && (
<AgentConfigurePage <AgentConfigurePage
agent={agent} agent={agent}
agentId={agent.id} agentId={agent.id}
@@ -750,8 +761,6 @@ function AgentOverview({
runs, runs,
assignedIssues, assignedIssues,
runtimeState, runtimeState,
reportsToAgent,
directReports,
agentId, agentId,
agentRouteId, agentRouteId,
}: { }: {
@@ -759,8 +768,6 @@ function AgentOverview({
runs: HeartbeatRun[]; runs: HeartbeatRun[];
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
runtimeState?: AgentRuntimeState; runtimeState?: AgentRuntimeState;
reportsToAgent: Agent | null;
directReports: Agent[];
agentId: string; agentId: string;
agentRouteId: string; agentRouteId: string;
}) { }) {
@@ -820,131 +827,6 @@ function AgentOverview({
<h3 className="text-sm font-medium">Costs</h3> <h3 className="text-sm font-medium">Costs</h3>
<CostsSection runtimeState={runtimeState} runs={runs} /> <CostsSection runtimeState={runtimeState} runs={runs} />
</div> </div>
{/* Configuration Summary */}
<ConfigSummary
agent={agent}
agentRouteId={agentRouteId}
reportsToAgent={reportsToAgent}
directReports={directReports}
/>
</div>
);
}
/* Chart components imported from ../components/ActivityCharts */
/* ---- Configuration Summary ---- */
function ConfigSummary({
agent,
agentRouteId,
reportsToAgent,
directReports,
}: {
agent: Agent;
agentRouteId: string;
reportsToAgent: Agent | null;
directReports: Agent[];
}) {
const config = agent.adapterConfig as Record<string, unknown>;
const promptText = typeof config?.promptTemplate === "string" ? config.promptTemplate : "";
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Configuration</h3>
<Link
to={`/agents/${agentRouteId}/configure`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors no-underline"
>
<Settings className="h-3 w-3" />
Manage &rarr;
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border border-border rounded-lg p-4 space-y-3">
<h4 className="text-xs text-muted-foreground font-medium">Agent Details</h4>
<div className="space-y-2 text-sm">
<SummaryRow label="Adapter">
<span className="font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
{String(config?.model ?? "") !== "" && (
<span className="text-muted-foreground ml-1">
({String(config.model)})
</span>
)}
</SummaryRow>
<SummaryRow label="Heartbeat">
{(agent.runtimeConfig as Record<string, unknown>)?.heartbeat
? (() => {
const hb = (agent.runtimeConfig as Record<string, unknown>).heartbeat as Record<string, unknown>;
if (!hb.enabled) return <span className="text-muted-foreground">Disabled</span>;
const sec = Number(hb.intervalSec) || 300;
const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1));
const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`;
return (
<span>
Every {intervalLabel}
{maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""}
</span>
);
})()
: <span className="text-muted-foreground">Not configured</span>
}
</SummaryRow>
<SummaryRow label="Last heartbeat">
{agent.lastHeartbeatAt
? <span>{relativeTime(agent.lastHeartbeatAt)}</span>
: <span className="text-muted-foreground">Never</span>
}
</SummaryRow>
<SummaryRow label="Reports to">
{reportsToAgent ? (
<Link
to={`/agents/${agentRouteRef(reportsToAgent)}`}
className="text-blue-600 hover:underline dark:text-blue-400"
>
<Identity name={reportsToAgent.name} size="sm" />
</Link>
) : (
<span className="text-muted-foreground">Nobody (top-level)</span>
)}
</SummaryRow>
</div>
{directReports.length > 0 && (
<div className="pt-1">
<span className="text-xs text-muted-foreground">Direct reports</span>
<div className="mt-1 space-y-1">
{directReports.map((r) => (
<Link
key={r.id}
to={`/agents/${agentRouteRef(r)}`}
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<span className="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
</span>
{r.name}
<span className="text-muted-foreground text-xs">({roleLabels[r.role] ?? r.role})</span>
</Link>
))}
</div>
</div>
)}
{agent.capabilities && (
<div className="pt-1">
<span className="text-xs text-muted-foreground">Capabilities</span>
<p className="text-sm mt-0.5">{agent.capabilities}</p>
</div>
)}
</div>
{promptText && (
<div className="border border-border rounded-lg p-4 space-y-2">
<h4 className="text-xs text-muted-foreground font-medium">Prompt Template</h4>
<pre className="text-xs text-muted-foreground line-clamp-[12] font-mono whitespace-pre-wrap">{promptText}</pre>
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared"; import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
@@ -11,20 +11,18 @@ import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties } from "../components/ProjectProperties"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { IssuesList } from "../components/IssuesList"; import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { projectRouteRef, cn } from "../lib/utils"; import { projectRouteRef, cn } from "../lib/utils";
import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SlidersHorizontal } from "lucide-react";
/* ── Top-level tab types ── */ /* ── Top-level tab types ── */
type ProjectTab = "overview" | "list"; type ProjectTab = "overview" | "list" | "configuration";
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
const segments = pathname.split("/").filter(Boolean); const segments = pathname.split("/").filter(Boolean);
@@ -32,6 +30,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
const tab = segments[projectsIdx + 2]; const tab = segments[projectsIdx + 2];
if (tab === "overview") return "overview"; if (tab === "overview") return "overview";
if (tab === "configuration") return "configuration";
if (tab === "issues") return "list"; if (tab === "issues") return "list";
return null; return null;
} }
@@ -198,12 +197,14 @@ export function ProjectDetail() {
filter?: string; filter?: string;
}>(); }>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
const routeProjectRef = projectId ?? ""; const routeProjectRef = projectId ?? "";
const routeCompanyId = useMemo(() => { const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null; if (!companyPrefix) return null;
@@ -264,6 +265,10 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return; return;
} }
if (activeTab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
return;
}
if (activeTab === "list") { if (activeTab === "list") {
if (filter) { if (filter) {
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
@@ -276,11 +281,52 @@ export function ProjectDetail() {
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]); }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
useEffect(() => { useEffect(() => {
if (project) { closePanel();
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
}
return () => closePanel(); return () => closePanel();
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps }, [closePanel]);
useEffect(() => {
return () => {
Object.values(fieldSaveTimers.current).forEach((timer) => {
if (timer) clearTimeout(timer);
});
};
}, []);
const setFieldState = useCallback((field: ProjectConfigFieldKey, state: ProjectFieldSaveState) => {
setFieldSaveStates((current) => ({ ...current, [field]: state }));
}, []);
const scheduleFieldReset = useCallback((field: ProjectConfigFieldKey, delayMs: number) => {
const existing = fieldSaveTimers.current[field];
if (existing) clearTimeout(existing);
fieldSaveTimers.current[field] = setTimeout(() => {
setFieldSaveStates((current) => {
const next = { ...current };
delete next[field];
return next;
});
delete fieldSaveTimers.current[field];
}, delayMs);
}, []);
const updateProjectField = useCallback(async (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
const requestId = (fieldSaveRequestIds.current[field] ?? 0) + 1;
fieldSaveRequestIds.current[field] = requestId;
setFieldState(field, "saving");
try {
await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId);
invalidateProject();
if (fieldSaveRequestIds.current[field] !== requestId) return;
setFieldState(field, "saved");
scheduleFieldReset(field, 1800);
} catch (error) {
if (fieldSaveRequestIds.current[field] !== requestId) return;
setFieldState(field, "error");
scheduleFieldReset(field, 3000);
throw error;
}
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
// Redirect bare /projects/:id to /projects/:id/issues // Redirect bare /projects/:id to /projects/:id/issues
if (routeProjectRef && activeTab === null) { if (routeProjectRef && activeTab === null) {
@@ -294,6 +340,8 @@ export function ProjectDetail() {
const handleTabChange = (tab: ProjectTab) => { const handleTabChange = (tab: ProjectTab) => {
if (tab === "overview") { if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`); navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`);
} else { } else {
navigate(`/projects/${canonicalProjectRef}/issues`); navigate(`/projects/${canonicalProjectRef}/issues`);
} }
@@ -314,54 +362,21 @@ export function ProjectDetail() {
as="h2" as="h2"
className="text-xl font-bold" className="text-xl font-bold"
/> />
<Button
variant="ghost"
size="icon-xs"
className="ml-auto md:hidden shrink-0"
onClick={() => setMobilePropsOpen(true)}
title="Properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"shrink-0 ml-auto transition-opacity duration-200 hidden md:flex",
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
)}
onClick={() => setPanelVisible(true)}
title="Show properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
</div> </div>
{/* Top-level project tabs */} <Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<div className="flex items-center gap-1 border-b border-border"> <PageTabBar
<button items={[
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${ { value: "overview", label: "Overview" },
activeTab === "overview" { value: "list", label: "List" },
? "border-foreground text-foreground" { value: "configuration", label: "Configuration" },
: "border-transparent text-muted-foreground hover:text-foreground" ]}
}`} align="start"
onClick={() => handleTabChange("overview")} value={activeTab ?? "list"}
> onValueChange={(value) => handleTabChange(value as ProjectTab)}
Overview />
</button> </Tabs>
<button
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${
activeTab === "list"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => handleTabChange("list")}
>
List
</button>
</div>
{/* Tab content */}
{activeTab === "overview" && ( {activeTab === "overview" && (
<OverviewContent <OverviewContent
project={project} project={project}
@@ -377,19 +392,16 @@ export function ProjectDetail() {
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} /> <ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
)} )}
{/* Mobile properties drawer */} {activeTab === "configuration" && (
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}> <div className="max-w-4xl">
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]"> <ProjectProperties
<SheetHeader> project={project}
<SheetTitle className="text-sm">Properties</SheetTitle> onUpdate={(data) => updateProject.mutate(data)}
</SheetHeader> onFieldUpdate={updateProjectField}
<ScrollArea className="flex-1 overflow-y-auto"> getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
<div className="px-4 pb-4"> />
<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} /> </div>
</div> )}
</ScrollArea>
</SheetContent>
</Sheet>
</div> </div>
); );
} }