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:
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
62
doc/experimental/issue-worktree-support.md
Normal file
62
doc/experimental/issue-worktree-support.md
Normal 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
|
||||||
@@ -3,6 +3,7 @@ export type {
|
|||||||
AdapterRuntime,
|
AdapterRuntime,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
AdapterBillingType,
|
AdapterBillingType,
|
||||||
|
AdapterRuntimeServiceReport,
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal file
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal 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");
|
||||||
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal file
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal 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;
|
||||||
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
64
packages/db/src/schema/workspace_runtime_services.ts
Normal file
64
packages/db/src/schema/workspace_runtime_services.ts
Normal 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,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
58
packages/shared/src/types/workspace-runtime.ts
Normal file
58
packages/shared/src/types/workspace-runtime.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
37
scripts/provision-worktree.sh
Normal file
37
scripts/provision-worktree.sh
Normal 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#^\./##'
|
||||||
|
)
|
||||||
143
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
143
server/src/__tests__/execution-workspace-policy.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
386
server/src/__tests__/workspace-runtime.test.ts
Normal file
386
server/src/__tests__/workspace-runtime.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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())
|
||||||
|
|||||||
143
server/src/services/execution-workspace-policy.ts
Normal file
143
server/src/services/execution-workspace-policy.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
1076
server/src/services/workspace-runtime.ts
Normal file
1076
server/src/services/workspace-runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
ui/src/adapters/local-workspace-runtime-fields.tsx
Normal file
5
ui/src/adapters/local-workspace-runtime-fields.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "./types";
|
||||||
|
|
||||||
|
export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
122
ui/src/adapters/runtime-json-fields.tsx
Normal file
122
ui/src/adapters/runtime-json-fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 project’s 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 project’s 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 →
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user