mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Compare commits
9 Commits
@paperclip
...
feature/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b754752164 | ||
|
|
98b0e2fca1 | ||
|
|
d7278199b6 | ||
|
|
99f6fafa1d | ||
|
|
d2949d0554 | ||
|
|
e19bfe110d | ||
|
|
fb195d2d64 | ||
|
|
a53e7eb780 | ||
|
|
22761167c2 |
@@ -47,6 +47,7 @@
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
99
cli/src/__tests__/doctor.test.ts
Normal file
99
cli/src/__tests__/doctor.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { doctor } from "../commands/doctor.js";
|
||||
import { writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeConfig(config, configPath);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("doctor", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: true,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.warned).toBe(0);
|
||||
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
||||
});
|
||||
});
|
||||
125
cli/src/__tests__/worktree.test.ts
Normal file
125
cli/src/__tests__/worktree.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
sanitizeWorktreeInstanceId,
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
function buildSourceConfig(): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-09T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/main/db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/main/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/main/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit",
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/main/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/main/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree helpers", () => {
|
||||
it("sanitizes instance ids", () => {
|
||||
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||
});
|
||||
|
||||
it("rewrites loopback auth URLs to the new port only", () => {
|
||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
it("builds isolated config and env paths for a worktree", () => {
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd: "/tmp/paperclip-feature",
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "feature-worktree-support",
|
||||
});
|
||||
const config = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths,
|
||||
serverPort: 3110,
|
||||
databasePort: 54339,
|
||||
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(config.database.embeddedPostgresDataDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
||||
);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54339);
|
||||
expect(config.server.port).toBe(3110);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
||||
expect(config.storage.localDisk.baseDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths);
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
|
||||
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
||||
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
||||
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
||||
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
||||
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
||||
|
||||
expect(full.excludedTables).toEqual([]);
|
||||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -66,28 +66,40 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -95,10 +107,13 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -120,9 +135,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -130,7 +145,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return;
|
||||
if (p.isCancel(answer)) return false;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -138,10 +153,30 @@ async function maybeRepair(
|
||||
try {
|
||||
await result.repair();
|
||||
p.log.success(`Repaired: ${result.name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runRepairableCheck(input: {
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
configPath: string;
|
||||
opts: { repair?: boolean; yes?: boolean };
|
||||
}): Promise<CheckResult> {
|
||||
let result = await input.run();
|
||||
printResult(result);
|
||||
|
||||
const repaired = await maybeRepair(result, input.opts);
|
||||
if (!repaired) return result;
|
||||
|
||||
// Repairs may create/update the adjacent .env file or other local resources.
|
||||
loadPaperclipEnvFile(input.configPath);
|
||||
result = await input.run();
|
||||
printResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
|
||||
217
cli/src/commands/worktree-lib.ts
Normal file
217
cli/src/commands/worktree-lib.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
|
||||
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
|
||||
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
|
||||
|
||||
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
|
||||
|
||||
export type WorktreeSeedPlan = {
|
||||
mode: WorktreeSeedMode;
|
||||
excludedTables: string[];
|
||||
nullifyColumns: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
|
||||
"activity_log",
|
||||
"agent_runtime_state",
|
||||
"agent_task_sessions",
|
||||
"agent_wakeup_requests",
|
||||
"cost_events",
|
||||
"heartbeat_run_events",
|
||||
"heartbeat_runs",
|
||||
"workspace_runtime_services",
|
||||
];
|
||||
|
||||
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
|
||||
issues: ["checkout_run_id", "execution_run_id"],
|
||||
};
|
||||
|
||||
export type WorktreeLocalPaths = {
|
||||
cwd: string;
|
||||
repoConfigDir: string;
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
homeDir: string;
|
||||
instanceId: string;
|
||||
instanceRoot: string;
|
||||
contextPath: string;
|
||||
embeddedPostgresDataDir: string;
|
||||
backupDir: string;
|
||||
logDir: string;
|
||||
secretsKeyFilePath: string;
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
|
||||
if (mode === "full") {
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [],
|
||||
nullifyColumns: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
|
||||
nullifyColumns: {
|
||||
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
instanceId: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
|
||||
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
|
||||
const repoConfigDir = path.resolve(cwd, ".paperclip");
|
||||
return {
|
||||
cwd,
|
||||
repoConfigDir,
|
||||
configPath: path.resolve(repoConfigDir, "config.json"),
|
||||
envPath: path.resolve(repoConfigDir, ".env"),
|
||||
homeDir,
|
||||
instanceId: opts.instanceId,
|
||||
instanceRoot,
|
||||
contextPath: path.resolve(homeDir, "context.json"),
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
backupDir: path.resolve(instanceRoot, "data", "backups"),
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
storageDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorktreeConfig(input: {
|
||||
sourceConfig: PaperclipConfig | null;
|
||||
paths: WorktreeLocalPaths;
|
||||
serverPort: number;
|
||||
databasePort: number;
|
||||
now?: Date;
|
||||
}): PaperclipConfig {
|
||||
const { sourceConfig, paths, serverPort, databasePort } = input;
|
||||
const nowIso = (input.now ?? new Date()).toISOString();
|
||||
|
||||
const source = sourceConfig;
|
||||
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
|
||||
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: nowIso,
|
||||
source: "configure",
|
||||
},
|
||||
...(source?.llm ? { llm: source.llm } : {}),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: databasePort,
|
||||
backup: {
|
||||
enabled: source?.database.backup.enabled ?? true,
|
||||
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
|
||||
retentionDays: source?.database.backup.retentionDays ?? 30,
|
||||
dir: paths.backupDir,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: source?.logging.mode ?? "file",
|
||||
logDir: paths.logDir,
|
||||
},
|
||||
server: {
|
||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||
exposure: source?.server.exposure ?? "private",
|
||||
host: source?.server.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||
serveUi: source?.server.serveUi ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
baseDir: paths.storageDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: source?.storage.s3.bucket ?? "paperclip",
|
||||
region: source?.storage.s3.region ?? "us-east-1",
|
||||
endpoint: source?.storage.s3.endpoint,
|
||||
prefix: source?.storage.s3.prefix ?? "",
|
||||
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: source?.secrets.provider ?? "local_encrypted",
|
||||
strictMode: source?.secrets.strictMode ?? false,
|
||||
localEncrypted: {
|
||||
keyFilePath: paths.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
||||
return {
|
||||
PAPERCLIP_HOME: paths.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
};
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function formatShellExports(entries: Record<string, string>): string {
|
||||
return Object.entries(entries)
|
||||
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
||||
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
403
cli/src/commands/worktree.ts
Normal file
403
cli/src/commands/worktree.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
DEFAULT_WORKTREE_HOME,
|
||||
formatShellExports,
|
||||
isWorktreeSeedMode,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
sanitizeWorktreeInstanceId,
|
||||
type WorktreeSeedMode,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
instance?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
serverPort?: number;
|
||||
dbPort?: number;
|
||||
seed?: boolean;
|
||||
seedMode?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||
const port = Number(lines[3]?.trim());
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number, reserved = new Set<number>()): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (reserved.has(port) || !(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function detectGitBranchName(cwd: string): string | null {
|
||||
try {
|
||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return nonEmpty(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"Source instance uses postgres mode but has no connection string in config or adjacent .env.",
|
||||
);
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
const port = portOverride ?? config.database.embeddedPostgresPort;
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||
const moduleName = "embedded-postgres";
|
||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||
try {
|
||||
const mod = await import(moduleName);
|
||||
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
if (runningPid) {
|
||||
return {
|
||||
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||
startedByThisProcess: false,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
}): Promise<string> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
try {
|
||||
if (input.sourceConfig.database.mode === "embedded-postgres") {
|
||||
sourceHandle = await ensureEmbeddedPostgres(
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
sourceHandle?.port,
|
||||
);
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
retentionDays: 7,
|
||||
filenamePrefix: `${input.instanceId}-seed`,
|
||||
includeMigrationJournal: true,
|
||||
excludeTables: seedPlan.excludedTables,
|
||||
nullifyColumns: seedPlan.nullifyColumns,
|
||||
});
|
||||
|
||||
targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
input.targetConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
|
||||
await runDatabaseRestore({
|
||||
connectionString: targetConnectionString,
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
|
||||
return formatDatabaseBackupResult(backup);
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
}
|
||||
if (sourceHandle?.startedByThisProcess) {
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||
);
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
|
||||
instanceId,
|
||||
});
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
|
||||
if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) {
|
||||
throw new Error(
|
||||
`Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
|
||||
ensureAgentJwtSecret(paths.configPath);
|
||||
loadPaperclipEnvFile(paths.configPath);
|
||||
|
||||
let seedSummary: string | null = null;
|
||||
if (opts.seed !== false) {
|
||||
if (!sourceConfig) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
|
||||
);
|
||||
}
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||
try {
|
||||
seedSummary = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths: paths,
|
||||
instanceId,
|
||||
seedMode,
|
||||
});
|
||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
p.log.message(pc.dim(`Repo config: ${paths.configPath}`));
|
||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||
if (seedSummary) {
|
||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||
}
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = resolvePaperclipEnvFile(configPath);
|
||||
const envEntries = readPaperclipEnvEntries(envPath);
|
||||
const out = {
|
||||
PAPERCLIP_CONFIG: configPath,
|
||||
...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}),
|
||||
...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}),
|
||||
...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}),
|
||||
...envEntries,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatShellExports(out));
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
worktree
|
||||
.command("init")
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeInitCommand);
|
||||
|
||||
worktree
|
||||
.command("env")
|
||||
.description("Print shell exports for the current worktree-local Paperclip instance")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
}
|
||||
@@ -25,13 +25,17 @@ function parseEnvFile(contents: string) {
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
@@ -82,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
||||
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
entries: Record<string, string>,
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -132,6 +135,7 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -124,6 +124,50 @@ When a local agent run has no resolved project/session workspace, Paperclip fall
|
||||
|
||||
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
|
||||
Instead, create a repo-local Paperclip config plus an isolated instance for the worktree:
|
||||
|
||||
```sh
|
||||
paperclipai worktree init
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
|
||||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- picks a free app port and embedded PostgreSQL port
|
||||
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
|
||||
|
||||
Seed modes:
|
||||
|
||||
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
|
||||
- `full` makes a full logical clone of the source instance
|
||||
- `--no-seed` creates an empty isolated instance
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
Print shell exports explicitly when needed:
|
||||
|
||||
```sh
|
||||
paperclipai worktree env
|
||||
# or:
|
||||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```sh
|
||||
paperclipai worktree init --no-seed
|
||||
paperclipai worktree init --seed-mode minimal
|
||||
paperclipai worktree init --seed-mode full
|
||||
paperclipai worktree init --from-instance default
|
||||
paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
|
||||
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface UsageSummary {
|
||||
|
||||
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 {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
}
|
||||
@@ -208,6 +230,12 @@ export interface CreateConfigValues {
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
|
||||
@@ -25,8 +25,13 @@ Core fields:
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout 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 workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || 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)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(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 useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (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) {
|
||||
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)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
|
||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
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> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
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;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
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.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -31,6 +31,8 @@ Core fields:
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
@@ -40,4 +42,5 @@ Notes:
|
||||
- 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.
|
||||
- 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 workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(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 useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (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) {
|
||||
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)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
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> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: 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.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -31,6 +31,7 @@ Gateway connect identity fields:
|
||||
|
||||
Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||
@@ -39,4 +40,15 @@ Request behavior fields:
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
||||
- 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 crypto, { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
@@ -411,6 +415,58 @@ function appendWakeText(baseText: string, wakeText: string): string {
|
||||
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 {
|
||||
try {
|
||||
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 {
|
||||
const record = asRecord(value);
|
||||
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 message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
||||
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
|
||||
|
||||
const agentParams: Record<string, unknown> = {
|
||||
...payloadTemplate,
|
||||
paperclip: paperclipPayload,
|
||||
message,
|
||||
sessionKey,
|
||||
idempotencyKey: ctx.runId,
|
||||
@@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
null;
|
||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||
|
||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const agentMeta = asRecord(meta?.agentMeta);
|
||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||
const acceptedResult = asRecord(acceptedPayload?.result);
|
||||
const latestPayload = asRecord(latestResultPayload);
|
||||
const latestResult = asRecord(latestPayload?.result);
|
||||
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
||||
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(
|
||||
"stdout",
|
||||
@@ -1209,6 +1364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...(usage ? { usage } : {}),
|
||||
...(costUsd > 0 ? { costUsd } : {}),
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
@@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
|
||||
ac.sessionKeyStrategy = "issue";
|
||||
ac.role = "operator";
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
@@ -9,6 +9,9 @@ export type RunDatabaseBackupOptions = {
|
||||
retentionDays: number;
|
||||
filenamePrefix?: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
includeMigrationJournal?: boolean;
|
||||
excludeTables?: string[];
|
||||
nullifyColumns?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type RunDatabaseBackupResult = {
|
||||
@@ -17,6 +20,50 @@ export type RunDatabaseBackupResult = {
|
||||
prunedCount: number;
|
||||
};
|
||||
|
||||
export type RunDatabaseRestoreOptions = {
|
||||
connectionString: string;
|
||||
backupFile: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type SequenceDefinition = {
|
||||
sequence_schema: string;
|
||||
sequence_name: string;
|
||||
data_type: string;
|
||||
start_value: string;
|
||||
minimum_value: string;
|
||||
maximum_value: string;
|
||||
increment: string;
|
||||
cycle_option: "YES" | "NO";
|
||||
owner_schema: string | null;
|
||||
owner_table: string | null;
|
||||
owner_column: string | null;
|
||||
};
|
||||
|
||||
type TableDefinition = {
|
||||
schema_name: string;
|
||||
tablename: string;
|
||||
};
|
||||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
|
||||
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
|
||||
|
||||
function sanitizeRestoreErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === "object") {
|
||||
const record = error as Record<string, unknown>;
|
||||
const firstLine = typeof record.message === "string"
|
||||
? record.message.split(/\r?\n/, 1)[0]?.trim()
|
||||
: "";
|
||||
const detail = typeof record.detail === "string" ? record.detail.trim() : "";
|
||||
const severity = typeof record.severity === "string" ? record.severity.trim() : "";
|
||||
const message = firstLine || detail || (error instanceof Error ? error.message : String(error));
|
||||
return severity ? `${severity}: ${message}` : message;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function timestamp(date: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
@@ -47,10 +94,60 @@ function formatBackupSize(sizeBytes: number): string {
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function formatSqlLiteral(value: string): string {
|
||||
const sanitized = value.replace(/\u0000/g, "");
|
||||
let tag = "$paperclip$";
|
||||
while (sanitized.includes(tag)) {
|
||||
tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`;
|
||||
}
|
||||
return `${tag}${sanitized}${tag}`;
|
||||
}
|
||||
|
||||
function normalizeTableNameSet(values: string[] | undefined): Set<string> {
|
||||
return new Set(
|
||||
(values ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNullifyColumnMap(values: Record<string, string[]> | undefined): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
if (!values) return out;
|
||||
for (const [tableName, columns] of Object.entries(values)) {
|
||||
const normalizedTable = tableName.trim();
|
||||
if (normalizedTable.length === 0) continue;
|
||||
const normalizedColumns = new Set(
|
||||
columns
|
||||
.map((column) => column.trim())
|
||||
.filter((column) => column.length > 0),
|
||||
);
|
||||
if (normalizedColumns.size > 0) {
|
||||
out.set(normalizedTable, normalizedColumns);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string): string {
|
||||
return `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function quoteQualifiedName(schemaName: string, objectName: string): string {
|
||||
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
|
||||
}
|
||||
|
||||
function tableKey(schemaName: string, tableName: string): string {
|
||||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
|
||||
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
|
||||
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
const includeMigrationJournal = opts.includeMigrationJournal === true;
|
||||
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
|
||||
try {
|
||||
@@ -58,13 +155,35 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
const lines: string[] = [];
|
||||
const emit = (line: string) => lines.push(line);
|
||||
const emitStatement = (statement: string) => {
|
||||
emit(statement);
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
};
|
||||
const emitStatementBoundary = () => {
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
};
|
||||
|
||||
emit("-- Paperclip database backup");
|
||||
emit(`-- Created: ${new Date().toISOString()}`);
|
||||
emit("");
|
||||
emit("BEGIN;");
|
||||
emitStatement("BEGIN;");
|
||||
emitStatement("SET LOCAL session_replication_role = replica;");
|
||||
emitStatement("SET LOCAL client_min_messages = warning;");
|
||||
emit("");
|
||||
|
||||
const allTables = await sql<TableDefinition[]>`
|
||||
SELECT table_schema AS schema_name, table_name AS tablename
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND (
|
||||
table_schema = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE})
|
||||
)
|
||||
ORDER BY table_schema, table_name
|
||||
`;
|
||||
const tables = allTables;
|
||||
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
|
||||
|
||||
// Get all enums
|
||||
const enums = await sql<{ typname: string; labels: string[] }[]>`
|
||||
SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
|
||||
@@ -78,23 +197,65 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
for (const e of enums) {
|
||||
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
|
||||
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
}
|
||||
if (enums.length > 0) emit("");
|
||||
|
||||
// Get tables in dependency order (referenced tables first)
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT c.relname AS tablename
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'r'
|
||||
AND c.relname != '__drizzle_migrations'
|
||||
ORDER BY c.relname
|
||||
const allSequences = await sql<SequenceDefinition[]>`
|
||||
SELECT
|
||||
s.sequence_schema,
|
||||
s.sequence_name,
|
||||
s.data_type,
|
||||
s.start_value,
|
||||
s.minimum_value,
|
||||
s.maximum_value,
|
||||
s.increment,
|
||||
s.cycle_option,
|
||||
tblns.nspname AS owner_schema,
|
||||
tbl.relname AS owner_table,
|
||||
attr.attname AS owner_column
|
||||
FROM information_schema.sequences s
|
||||
JOIN pg_class seq ON seq.relname = s.sequence_name
|
||||
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
|
||||
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
|
||||
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
|
||||
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
|
||||
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
|
||||
WHERE s.sequence_schema = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA})
|
||||
ORDER BY s.sequence_schema, s.sequence_name
|
||||
`;
|
||||
const sequences = allSequences.filter(
|
||||
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
|
||||
);
|
||||
|
||||
const schemas = new Set<string>();
|
||||
for (const table of tables) schemas.add(table.schema_name);
|
||||
for (const seq of sequences) schemas.add(seq.sequence_schema);
|
||||
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
|
||||
if (extraSchemas.length > 0) {
|
||||
emit("-- Schemas");
|
||||
for (const schemaName of extraSchemas) {
|
||||
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequences");
|
||||
for (const seq of sequences) {
|
||||
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
|
||||
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
|
||||
emitStatement(
|
||||
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Get full CREATE TABLE DDL via column info
|
||||
for (const { tablename } of tables) {
|
||||
for (const { schema_name, tablename } of tables) {
|
||||
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
|
||||
const columns = await sql<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -108,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
SELECT column_name, data_type, udt_name, is_nullable, column_default,
|
||||
character_maximum_length, numeric_precision, numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
emit(`-- Table: ${tablename}`);
|
||||
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
|
||||
emit(`-- Table: ${schema_name}.${tablename}`);
|
||||
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
|
||||
|
||||
const colDefs: string[] = [];
|
||||
for (const col of columns) {
|
||||
@@ -149,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||||
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
GROUP BY c.conname
|
||||
`;
|
||||
for (const p of pk) {
|
||||
@@ -157,17 +318,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
|
||||
}
|
||||
|
||||
emit(`CREATE TABLE "${tablename}" (`);
|
||||
emit(`CREATE TABLE ${qualifiedTableName} (`);
|
||||
emit(colDefs.join(",\n"));
|
||||
emit(");");
|
||||
emitStatementBoundary();
|
||||
emit("");
|
||||
}
|
||||
|
||||
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
|
||||
if (ownedSequences.length > 0) {
|
||||
emit("-- Sequence ownership");
|
||||
for (const seq of ownedSequences) {
|
||||
emitStatement(
|
||||
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Foreign keys (after all tables created)
|
||||
const fks = await sql<{
|
||||
const allForeignKeys = await sql<{
|
||||
constraint_name: string;
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_columns: string[];
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_columns: string[];
|
||||
update_rule: string;
|
||||
@@ -175,137 +350,157 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
}[]>`
|
||||
SELECT
|
||||
c.conname AS constraint_name,
|
||||
srcn.nspname AS source_schema,
|
||||
src.relname AS source_table,
|
||||
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
|
||||
tgtn.nspname AS target_schema,
|
||||
tgt.relname AS target_table,
|
||||
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
|
||||
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
|
||||
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
|
||||
JOIN pg_class tgt ON tgt.oid = c.confrelid
|
||||
JOIN pg_namespace n ON n.oid = src.relnamespace
|
||||
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
|
||||
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
|
||||
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
|
||||
WHERE c.contype = 'f' AND n.nspname = 'public'
|
||||
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY src.relname, c.conname
|
||||
WHERE c.contype = 'f' AND (
|
||||
srcn.nspname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY srcn.nspname, src.relname, c.conname
|
||||
`;
|
||||
const fks = allForeignKeys.filter(
|
||||
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
|
||||
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
|
||||
);
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emit(
|
||||
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Unique constraints
|
||||
const uniques = await sql<{
|
||||
const allUniqueConstraints = await sql<{
|
||||
constraint_name: string;
|
||||
schema_name: string;
|
||||
tablename: string;
|
||||
column_names: string[];
|
||||
}[]>`
|
||||
SELECT c.conname AS constraint_name,
|
||||
n.nspname AS schema_name,
|
||||
t.relname AS tablename,
|
||||
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||||
WHERE n.nspname = 'public' AND c.contype = 'u'
|
||||
GROUP BY c.conname, t.relname
|
||||
ORDER BY t.relname, c.conname
|
||||
WHERE c.contype = 'u' AND (
|
||||
n.nspname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
GROUP BY c.conname, n.nspname, t.relname
|
||||
ORDER BY n.nspname, t.relname, c.conname
|
||||
`;
|
||||
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
|
||||
|
||||
if (uniques.length > 0) {
|
||||
emit("-- Unique constraints");
|
||||
for (const u of uniques) {
|
||||
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
|
||||
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Indexes (non-primary, non-unique-constraint)
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
SELECT indexdef
|
||||
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
|
||||
SELECT schemaname AS schema_name, tablename, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname NOT IN (
|
||||
SELECT conname FROM pg_constraint
|
||||
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
||||
WHERE (
|
||||
schemaname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
ORDER BY tablename, indexname
|
||||
AND indexname NOT IN (
|
||||
SELECT conname FROM pg_constraint c
|
||||
JOIN pg_namespace n ON n.oid = c.connamespace
|
||||
WHERE n.nspname = pg_indexes.schemaname
|
||||
)
|
||||
ORDER BY schemaname, tablename, indexname
|
||||
`;
|
||||
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
|
||||
|
||||
if (indexes.length > 0) {
|
||||
emit("-- Indexes");
|
||||
for (const idx of indexes) {
|
||||
emit(`${idx.indexdef};`);
|
||||
emitStatement(`${idx.indexdef};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Dump data for each table
|
||||
for (const { tablename } of tables) {
|
||||
const count = await sql<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n FROM ${sql(tablename)}
|
||||
`;
|
||||
if ((count[0]?.n ?? 0) === 0) continue;
|
||||
for (const { schema_name, tablename } of tables) {
|
||||
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
|
||||
const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
|
||||
if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue;
|
||||
|
||||
// Get column info for this table
|
||||
const cols = await sql<{ column_name: string; data_type: string }[]>`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
|
||||
|
||||
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
|
||||
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
|
||||
|
||||
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
|
||||
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
|
||||
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
|
||||
for (const row of rows) {
|
||||
const values = row.map((val: unknown) => {
|
||||
const values = row.map((rawValue: unknown, index) => {
|
||||
const columnName = cols[index]?.column_name;
|
||||
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (typeof val === "boolean") return val ? "true" : "false";
|
||||
if (typeof val === "number") return String(val);
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`;
|
||||
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
|
||||
return `'${String(val).replace(/'/g, "''")}'`;
|
||||
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
|
||||
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
|
||||
return formatSqlLiteral(String(val));
|
||||
});
|
||||
emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`);
|
||||
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Sequence values
|
||||
const sequences = await sql<{ sequence_name: string }[]>`
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
WHERE sequence_schema = 'public'
|
||||
ORDER BY sequence_name
|
||||
`;
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequence values");
|
||||
for (const seq of sequences) {
|
||||
const val = await sql<{ last_value: string }[]>`
|
||||
SELECT last_value::text FROM ${sql(seq.sequence_name)}
|
||||
`;
|
||||
if (val[0]) {
|
||||
emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`);
|
||||
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
|
||||
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
|
||||
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
|
||||
);
|
||||
const skipSequenceValue =
|
||||
seq.owner_table !== null
|
||||
&& excludedTableNames.has(seq.owner_table);
|
||||
if (val[0] && !skipSequenceValue) {
|
||||
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
|
||||
}
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
emit("COMMIT;");
|
||||
emitStatement("COMMIT;");
|
||||
emit("");
|
||||
|
||||
// Write the backup file
|
||||
@@ -326,6 +521,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
const contents = await readFile(opts.backupFile, "utf8");
|
||||
const statements = contents
|
||||
.split(STATEMENT_BREAKPOINT)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
await sql.unsafe(statement).execute();
|
||||
}
|
||||
} catch (error) {
|
||||
const statementPreview = typeof error === "object" && error !== null && typeof (error as Record<string, unknown>).query === "string"
|
||||
? String((error as Record<string, unknown>).query)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("--"))
|
||||
: null;
|
||||
throw new Error(
|
||||
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`,
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string {
|
||||
const size = formatBackupSize(result.sizeBytes);
|
||||
const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : "";
|
||||
|
||||
@@ -10,6 +10,10 @@ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
|
||||
|
||||
function createUtilitySql(url: string) {
|
||||
return postgres(url, { max: 1, onnotice: () => {} });
|
||||
}
|
||||
|
||||
function isSafeIdentifier(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
@@ -223,7 +227,7 @@ async function applyPendingMigrationsManually(
|
||||
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
|
||||
);
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
try {
|
||||
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
|
||||
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
|
||||
@@ -472,7 +476,7 @@ export async function reconcilePendingMigrationHistory(
|
||||
return { repairedMigrations: [], remainingMigrations: [] };
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
const repairedMigrations: string[] = [];
|
||||
|
||||
try {
|
||||
@@ -579,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
|
||||
}
|
||||
|
||||
export async function inspectMigrations(url: string): Promise<MigrationState> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const availableMigrations = await listMigrationFiles();
|
||||
@@ -642,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
|
||||
const initialState = await inspectMigrations(url);
|
||||
if (initialState.status === "upToDate") return;
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const db = drizzlePg(sql);
|
||||
@@ -680,7 +684,7 @@ export type MigrationBootstrapResult =
|
||||
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
|
||||
|
||||
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const migrationTableSchema = await discoverMigrationTableSchema(sql);
|
||||
@@ -719,7 +723,7 @@ export async function ensurePostgresDatabase(
|
||||
throw new Error(`Unsafe database name: ${databaseName}`);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
try {
|
||||
const existing = await sql<{ one: number }[]>`
|
||||
select 1 as one from pg_database where datname = ${databaseName} limit 1
|
||||
|
||||
@@ -12,8 +12,10 @@ export {
|
||||
} from "./client.js";
|
||||
export {
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
formatDatabaseBackupResult,
|
||||
type RunDatabaseBackupOptions,
|
||||
type RunDatabaseBackupResult,
|
||||
type RunDatabaseRestoreOptions,
|
||||
} from "./backup-lib.js";
|
||||
export * from "./schema/index.js";
|
||||
|
||||
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,
|
||||
"tag": "0025_nasty_salo",
|
||||
"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 { projects } from "./projects.js";
|
||||
export { projectWorkspaces } from "./project_workspaces.js";
|
||||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
|
||||
@@ -40,6 +40,7 @@ export const issues = pgTable(
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_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 { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
@@ -15,6 +15,7 @@ export const projects = pgTable(
|
||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_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,
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
@@ -156,9 +162,11 @@ export {
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
|
||||
@@ -11,6 +11,14 @@ export type {
|
||||
} from "./agent.js";
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
} from "./workspace-runtime.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type { Goal } from "./goal.js";
|
||||
import type { Project, ProjectWorkspace } from "./project.js";
|
||||
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
|
||||
export interface IssueAncestorProject {
|
||||
id: string;
|
||||
@@ -73,6 +74,7 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
|
||||
export interface ProjectGoalRef {
|
||||
id: string;
|
||||
@@ -15,6 +16,7 @@ export interface ProjectWorkspace {
|
||||
repoRef: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
isPrimary: boolean;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -33,6 +35,7 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
archivedAt: Date | null;
|
||||
|
||||
56
packages/shared/src/types/workspace-runtime.ts
Normal file
56
packages/shared/src/types/workspace-runtime.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
type CreateProject,
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
} from "./project.js";
|
||||
|
||||
export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
@@ -66,6 +69,7 @@ export {
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
type CheckoutIssue,
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { z } from "zod";
|
||||
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(),
|
||||
})
|
||||
.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
|
||||
.object({
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
@@ -21,6 +38,7 @@ export const createIssueSchema = z.object({
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||
labelIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
@@ -39,6 +57,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
});
|
||||
|
||||
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
|
||||
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
|
||||
|
||||
export const checkoutIssueSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { z } from "zod";
|
||||
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(),
|
||||
})
|
||||
.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 = {
|
||||
name: z.string().min(1).optional(),
|
||||
cwd: z.string().min(1).optional().nullable(),
|
||||
@@ -43,6 +65,7 @@ const projectFields = {
|
||||
leadAgentId: z.string().uuid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
color: z.string().optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
};
|
||||
|
||||
@@ -56,3 +79,5 @@ export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||
export const updateProjectSchema = z.object(projectFields).partial();
|
||||
|
||||
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
|
||||
|
||||
137
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
137
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
issueSettings: null,
|
||||
mode: "isolated",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
|
||||
expect(result.workspaceStrategy).toEqual({
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
});
|
||||
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",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
},
|
||||
});
|
||||
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 { WebSocketServer } from "ws";
|
||||
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";
|
||||
|
||||
function buildContext(
|
||||
@@ -36,7 +39,9 @@ function buildContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer() {
|
||||
async function createMockGatewayServer(options?: {
|
||||
waitPayload?: Record<string, unknown>;
|
||||
}) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
payload: options?.waitPayload ?? {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
|
||||
onLog: async (_stream, 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("PAPERCLIP_RUN_ID=run-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);
|
||||
} finally {
|
||||
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
|
||||
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 () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
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", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
|
||||
300
server/src/__tests__/workspace-runtime.test.ts
Normal file
300
server/src/__tests__/workspace-runtime.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export async function createApp(
|
||||
db: Db,
|
||||
opts: {
|
||||
uiMode: UiMode;
|
||||
serverPort: number;
|
||||
storageService: StorageService;
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
@@ -146,12 +147,18 @@ export async function createApp(
|
||||
|
||||
if (opts.uiMode === "vite-dev") {
|
||||
const uiRoot = path.resolve(__dirname, "../../ui");
|
||||
const hmrPort = opts.serverPort + 10000;
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
appType: "spa",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
host: opts.bindHost,
|
||||
port: hmrPort,
|
||||
clientPort: hmrPort,
|
||||
},
|
||||
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { createApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { logger } from "./middleware/logger.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 { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
@@ -460,10 +460,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
authReady = true;
|
||||
}
|
||||
|
||||
const listenPort = await detectPort(config.port);
|
||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||
const storageService = createStorageServiceFromConfig(config);
|
||||
const app = await createApp(db as any, {
|
||||
uiMode,
|
||||
serverPort: listenPort,
|
||||
storageService,
|
||||
deploymentMode: config.deploymentMode,
|
||||
deploymentExposure: config.deploymentExposure,
|
||||
@@ -475,7 +477,6 @@ export async function startServer(): Promise<StartedServer> {
|
||||
resolveSession,
|
||||
});
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
const listenPort = await detectPort(config.port);
|
||||
|
||||
if (listenPort !== config.port) {
|
||||
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
|
||||
@@ -494,6 +495,19 @@ export async function startServer(): Promise<StartedServer> {
|
||||
deploymentMode: config.deploymentMode,
|
||||
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) {
|
||||
const heartbeat = heartbeatService(db as any);
|
||||
@@ -502,7 +516,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
void heartbeat.reapOrphanedRuns().catch((err) => {
|
||||
logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
|
||||
});
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
void heartbeat
|
||||
.tickTimers(new Date())
|
||||
|
||||
141
server/src/services/execution-workspace-policy.ts
Normal file
141
server/src/services/execution-workspace-policy.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
costEvents,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
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 { secretService } from "./secrets.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 HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
@@ -406,6 +421,7 @@ function resolveNextSessionState(input: {
|
||||
export function heartbeatService(db: Db) {
|
||||
const runLogStore = getRunLogStore();
|
||||
const secretsSvc = secretService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
|
||||
async function getAgent(agentId: string) {
|
||||
return db
|
||||
@@ -1071,8 +1087,10 @@ export function heartbeatService(db: Db) {
|
||||
const issueAssigneeConfig = issueId
|
||||
? await db
|
||||
.select({
|
||||
projectId: issues.projectId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
@@ -1084,6 +1102,18 @@ export function heartbeatService(db: Db) {
|
||||
issueAssigneeConfig.assigneeAdapterOverrides,
|
||||
)
|
||||
: 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
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
: null;
|
||||
@@ -1093,20 +1123,72 @@ export function heartbeatService(db: Db) {
|
||||
const previousSessionParams = normalizeSessionParams(
|
||||
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(
|
||||
agent,
|
||||
context,
|
||||
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({
|
||||
agentId: agent.id,
|
||||
previousSessionParams,
|
||||
resolvedWorkspace,
|
||||
resolvedWorkspace: {
|
||||
...resolvedWorkspace,
|
||||
cwd: executionWorkspace.cwd,
|
||||
},
|
||||
});
|
||||
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
|
||||
const runtimeWorkspaceWarnings = [
|
||||
...resolvedWorkspace.warnings,
|
||||
...executionWorkspace.warnings,
|
||||
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
|
||||
...(resetTaskSession && sessionResetReason
|
||||
? [
|
||||
@@ -1117,16 +1199,33 @@ export function heartbeatService(db: Db) {
|
||||
: []),
|
||||
];
|
||||
context.paperclipWorkspace = {
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
source: resolvedWorkspace.source,
|
||||
projectId: resolvedWorkspace.projectId,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
repoUrl: resolvedWorkspace.repoUrl,
|
||||
repoRef: resolvedWorkspace.repoRef,
|
||||
cwd: executionWorkspace.cwd,
|
||||
source: executionWorkspace.source,
|
||||
mode: executionWorkspaceMode,
|
||||
strategy: executionWorkspace.strategy,
|
||||
projectId: executionWorkspace.projectId,
|
||||
workspaceId: executionWorkspace.workspaceId,
|
||||
repoUrl: executionWorkspace.repoUrl,
|
||||
repoRef: executionWorkspace.repoRef,
|
||||
branchName: executionWorkspace.branchName,
|
||||
worktreePath: executionWorkspace.worktreePath,
|
||||
};
|
||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
||||
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||
context.projectId = resolvedWorkspace.projectId;
|
||||
const runtimeServiceIntents = (() => {
|
||||
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 previousSessionDisplayId = truncateDisplayId(
|
||||
@@ -1146,7 +1245,6 @@ export function heartbeatService(db: Db) {
|
||||
let handle: RunLogHandle | null = null;
|
||||
let stdoutExcerpt = "";
|
||||
let stderrExcerpt = "";
|
||||
|
||||
try {
|
||||
const startedAt = run.startedAt ?? new Date();
|
||||
const runningWithSession = await db
|
||||
@@ -1154,6 +1252,7 @@ export function heartbeatService(db: Db) {
|
||||
.set({
|
||||
startedAt,
|
||||
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
|
||||
contextSnapshot: context,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id))
|
||||
@@ -1235,15 +1334,54 @@ export function heartbeatService(db: Db) {
|
||||
for (const warning of runtimeWorkspaceWarnings) {
|
||||
await onLog("stderr", `[paperclip] ${warning}\n`);
|
||||
}
|
||||
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
||||
: config;
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
const adapterEnv = Object.fromEntries(
|
||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
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) => {
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
@@ -1284,6 +1422,54 @@ export function heartbeatService(db: Db) {
|
||||
onMeta: onAdapterMeta,
|
||||
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({
|
||||
codec: sessionCodec,
|
||||
adapterResult,
|
||||
@@ -1460,6 +1646,7 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
await finalizeAgentStatus(agent.id, "failed");
|
||||
} finally {
|
||||
await releaseRuntimeServicesForRun(run.id);
|
||||
await startNextQueuedRunForAgent(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,5 @@ export { companyPortabilityService } from "./company-portability.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import { extractProjectMentionIds } from "@paperclipai/shared";
|
||||
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"];
|
||||
|
||||
@@ -635,6 +639,19 @@ export function issueService(db: Db) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
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
|
||||
.update(companies)
|
||||
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
|
||||
@@ -644,7 +661,13 @@ export function issueService(db: Db) {
|
||||
const issueNumber = company.issueCounter;
|
||||
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) {
|
||||
values.startedAt = new Date();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import {
|
||||
PROJECT_COLORS,
|
||||
deriveProjectUrlKey,
|
||||
isUuidLike,
|
||||
normalizeProjectUrlKey,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
type CreateWorkspaceInput = {
|
||||
name?: string | null;
|
||||
@@ -23,10 +28,11 @@ type CreateWorkspaceInput = {
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
interface ProjectWithGoals extends ProjectRow {
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
}
|
||||
@@ -74,11 +80,46 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
||||
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||
goalIds: g.map((x) => x.id),
|
||||
goals: g,
|
||||
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
|
||||
} 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 {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
@@ -89,15 +130,20 @@ function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
|
||||
repoRef: row.repoRef ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
isPrimary: row.isPrimary,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
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;
|
||||
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. */
|
||||
@@ -110,6 +156,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
.from(projectWorkspaces)
|
||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||
.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[]>();
|
||||
for (const row of workspaceRows) {
|
||||
@@ -123,11 +180,16 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
|
||||
return rows.map((row) => {
|
||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||
const workspaces = projectWorkspaceRows.map(toWorkspace);
|
||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||
toWorkspace(
|
||||
workspace,
|
||||
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
|
||||
),
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
workspaces,
|
||||
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows),
|
||||
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -402,7 +464,18 @@ export function projectService(db: Db) {
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.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 (
|
||||
|
||||
962
server/src/services/workspace-runtime.ts
Normal file
962
server/src/services/workspace-runtime.ts
Normal file
@@ -0,0 +1,962 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
||||
import { resolveHomeAwarePath } from "../home-paths.js";
|
||||
|
||||
export interface ExecutionWorkspaceInput {
|
||||
baseCwd: string;
|
||||
source: "project_primary" | "task_session" | "agent_home";
|
||||
projectId: string | null;
|
||||
workspaceId: string | null;
|
||||
repoUrl: string | null;
|
||||
repoRef: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceIssueRef {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionWorkspaceAgentRef {
|
||||
id: string;
|
||||
name: string;
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
|
||||
strategy: "project_primary" | "git_worktree";
|
||||
cwd: string;
|
||||
branchName: string | null;
|
||||
worktreePath: string | null;
|
||||
warnings: string[];
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeServiceRef {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
serviceName: string;
|
||||
status: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle: "shared" | "ephemeral";
|
||||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
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: string;
|
||||
startedAt: string;
|
||||
stoppedAt: string | null;
|
||||
stopPolicy: Record<string, unknown> | null;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||
reused: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeServiceRecord extends RuntimeServiceRef {
|
||||
db?: Db;
|
||||
child: ChildProcess | null;
|
||||
leaseRunIds: Set<string>;
|
||||
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
|
||||
envFingerprint: string;
|
||||
}
|
||||
|
||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function stableRuntimeServiceId(input: {
|
||||
adapterType: string;
|
||||
runId: string;
|
||||
scopeType: RuntimeServiceRef["scopeType"];
|
||||
scopeId: string | null;
|
||||
serviceName: string;
|
||||
reportId: string | null;
|
||||
providerRef: string | null;
|
||||
reuseKey: string | null;
|
||||
}) {
|
||||
if (input.reportId) return input.reportId;
|
||||
const digest = createHash("sha256")
|
||||
.update(
|
||||
stableStringify({
|
||||
adapterType: input.adapterType,
|
||||
runId: input.runId,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
serviceName: input.serviceName,
|
||||
providerRef: input.providerRef,
|
||||
reuseKey: input.reuseKey,
|
||||
}),
|
||||
)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return `${input.adapterType}-${digest}`;
|
||||
}
|
||||
|
||||
function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<RuntimeServiceRef>): RuntimeServiceRef {
|
||||
return {
|
||||
id: record.id,
|
||||
companyId: record.companyId,
|
||||
projectId: record.projectId,
|
||||
projectWorkspaceId: record.projectWorkspaceId,
|
||||
issueId: record.issueId,
|
||||
serviceName: record.serviceName,
|
||||
status: record.status,
|
||||
lifecycle: record.lifecycle,
|
||||
scopeType: record.scopeType,
|
||||
scopeId: record.scopeId,
|
||||
reuseKey: record.reuseKey,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
port: record.port,
|
||||
url: record.url,
|
||||
provider: record.provider,
|
||||
providerRef: record.providerRef,
|
||||
ownerAgentId: record.ownerAgentId,
|
||||
startedByRunId: record.startedByRunId,
|
||||
lastUsedAt: record.lastUsedAt,
|
||||
startedAt: record.startedAt,
|
||||
stoppedAt: record.stoppedAt,
|
||||
stopPolicy: record.stopPolicy,
|
||||
healthStatus: record.healthStatus,
|
||||
reused: record.reused,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
|
||||
const raw = (value ?? "").trim().toLowerCase();
|
||||
const normalized = raw
|
||||
.replace(/[^a-z0-9/_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-/]+|[-/]+$/g, "");
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function renderWorkspaceTemplate(template: string, input: {
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
projectId: string | null;
|
||||
repoRef: string | null;
|
||||
}) {
|
||||
const issueIdentifier = input.issue?.identifier ?? input.issue?.id ?? "issue";
|
||||
const slug = sanitizeSlugPart(input.issue?.title, sanitizeSlugPart(issueIdentifier, "issue"));
|
||||
return renderTemplate(template, {
|
||||
issue: {
|
||||
id: input.issue?.id ?? "",
|
||||
identifier: input.issue?.identifier ?? "",
|
||||
title: input.issue?.title ?? "",
|
||||
},
|
||||
agent: {
|
||||
id: input.agent.id,
|
||||
name: input.agent.name,
|
||||
},
|
||||
project: {
|
||||
id: input.projectId ?? "",
|
||||
},
|
||||
workspace: {
|
||||
repoRef: input.repoRef ?? "",
|
||||
},
|
||||
slug,
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeBranchName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-/.]+|[-/.]+$/g, "")
|
||||
.slice(0, 120) || "paperclip-work";
|
||||
}
|
||||
|
||||
function isAbsolutePath(value: string) {
|
||||
return path.isAbsolute(value) || value.startsWith("~");
|
||||
}
|
||||
|
||||
function resolveConfiguredPath(value: string, baseDir: string): string {
|
||||
if (isAbsolutePath(value)) {
|
||||
return resolveHomeAwarePath(value);
|
||||
}
|
||||
return path.resolve(baseDir, value);
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
if (proc.code !== 0) {
|
||||
throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`);
|
||||
}
|
||||
return proc.stdout.trim();
|
||||
}
|
||||
|
||||
async function directoryExists(value: string) {
|
||||
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
|
||||
}
|
||||
|
||||
export async function realizeExecutionWorkspace(input: {
|
||||
base: ExecutionWorkspaceInput;
|
||||
config: Record<string, unknown>;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
}): Promise<RealizedExecutionWorkspace> {
|
||||
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
||||
const strategyType = asString(rawStrategy.type, "project_primary");
|
||||
if (strategyType !== "git_worktree") {
|
||||
return {
|
||||
...input.base,
|
||||
strategy: "project_primary",
|
||||
cwd: input.base.baseCwd,
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
||||
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
projectId: input.base.projectId,
|
||||
repoRef: input.base.repoRef,
|
||||
});
|
||||
const branchName = sanitizeBranchName(renderedBranch);
|
||||
const configuredParentDir = asString(rawStrategy.worktreeParentDir, "");
|
||||
const worktreeParentDir = configuredParentDir
|
||||
? resolveConfiguredPath(configuredParentDir, repoRoot)
|
||||
: path.join(repoRoot, ".paperclip", "worktrees");
|
||||
const worktreePath = path.join(worktreeParentDir, branchName);
|
||||
const baseRef = asString(rawStrategy.baseRef, input.base.repoRef ?? "HEAD");
|
||||
|
||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||
|
||||
const existingWorktree = await directoryExists(worktreePath);
|
||||
if (existingWorktree) {
|
||||
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
|
||||
if (existingGitDir) {
|
||||
return {
|
||||
...input.base,
|
||||
strategy: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
branchName,
|
||||
worktreePath,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
|
||||
}
|
||||
|
||||
await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot);
|
||||
|
||||
return {
|
||||
...input.base,
|
||||
strategy: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
branchName,
|
||||
worktreePath,
|
||||
warnings: [],
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Failed to allocate port"));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTemplateData(input: {
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
adapterEnv: Record<string, string>;
|
||||
port: number | null;
|
||||
}) {
|
||||
return {
|
||||
workspace: {
|
||||
cwd: input.workspace.cwd,
|
||||
branchName: input.workspace.branchName ?? "",
|
||||
worktreePath: input.workspace.worktreePath ?? "",
|
||||
repoUrl: input.workspace.repoUrl ?? "",
|
||||
repoRef: input.workspace.repoRef ?? "",
|
||||
env: input.adapterEnv,
|
||||
},
|
||||
issue: {
|
||||
id: input.issue?.id ?? "",
|
||||
identifier: input.issue?.identifier ?? "",
|
||||
title: input.issue?.title ?? "",
|
||||
},
|
||||
agent: {
|
||||
id: input.agent.id,
|
||||
name: input.agent.name,
|
||||
},
|
||||
port: input.port ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServiceScopeId(input: {
|
||||
service: Record<string, unknown>;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
runId: string;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
}): {
|
||||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
} {
|
||||
const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run");
|
||||
const scopeType =
|
||||
scopeTypeRaw === "project_workspace" ||
|
||||
scopeTypeRaw === "execution_workspace" ||
|
||||
scopeTypeRaw === "agent"
|
||||
? scopeTypeRaw
|
||||
: "run";
|
||||
if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId };
|
||||
if (scopeType === "execution_workspace") return { scopeType, scopeId: input.workspace.cwd };
|
||||
if (scopeType === "agent") return { scopeType, scopeId: input.agent.id };
|
||||
return { scopeType: "run" as const, scopeId: input.runId };
|
||||
}
|
||||
|
||||
async function waitForReadiness(input: {
|
||||
service: Record<string, unknown>;
|
||||
url: string | null;
|
||||
}) {
|
||||
const readiness = parseObject(input.service.readiness);
|
||||
const readinessType = asString(readiness.type, "");
|
||||
if (readinessType !== "http" || !input.url) return;
|
||||
const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30));
|
||||
const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500));
|
||||
const deadline = Date.now() + timeoutSec * 1000;
|
||||
let lastError = "service did not become ready";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(input.url);
|
||||
if (response.ok) return;
|
||||
lastError = `received HTTP ${response.status}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
||||
}
|
||||
|
||||
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
||||
return {
|
||||
id: record.id,
|
||||
companyId: record.companyId,
|
||||
projectId: record.projectId,
|
||||
projectWorkspaceId: record.projectWorkspaceId,
|
||||
issueId: record.issueId,
|
||||
scopeType: record.scopeType,
|
||||
scopeId: record.scopeId,
|
||||
serviceName: record.serviceName,
|
||||
status: record.status,
|
||||
lifecycle: record.lifecycle,
|
||||
reuseKey: record.reuseKey,
|
||||
command: record.command,
|
||||
cwd: record.cwd,
|
||||
port: record.port,
|
||||
url: record.url,
|
||||
provider: record.provider,
|
||||
providerRef: record.providerRef,
|
||||
ownerAgentId: record.ownerAgentId,
|
||||
startedByRunId: record.startedByRunId,
|
||||
lastUsedAt: new Date(record.lastUsedAt),
|
||||
startedAt: new Date(record.startedAt),
|
||||
stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null,
|
||||
stopPolicy: record.stopPolicy,
|
||||
healthStatus: record.healthStatus,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeServiceRecord) {
|
||||
if (!db) return;
|
||||
const values = toPersistedWorkspaceRuntimeService(record);
|
||||
await db
|
||||
.insert(workspaceRuntimeServices)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: workspaceRuntimeServices.id,
|
||||
set: {
|
||||
projectId: values.projectId,
|
||||
projectWorkspaceId: values.projectWorkspaceId,
|
||||
issueId: values.issueId,
|
||||
scopeType: values.scopeType,
|
||||
scopeId: values.scopeId,
|
||||
serviceName: values.serviceName,
|
||||
status: values.status,
|
||||
lifecycle: values.lifecycle,
|
||||
reuseKey: values.reuseKey,
|
||||
command: values.command,
|
||||
cwd: values.cwd,
|
||||
port: values.port,
|
||||
url: values.url,
|
||||
provider: values.provider,
|
||||
providerRef: values.providerRef,
|
||||
ownerAgentId: values.ownerAgentId,
|
||||
startedByRunId: values.startedByRunId,
|
||||
lastUsedAt: values.lastUsedAt,
|
||||
startedAt: values.startedAt,
|
||||
stoppedAt: values.stoppedAt,
|
||||
stopPolicy: values.stopPolicy,
|
||||
healthStatus: values.healthStatus,
|
||||
updatedAt: values.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearIdleTimer(record: RuntimeServiceRecord) {
|
||||
if (!record.idleTimer) return;
|
||||
clearTimeout(record.idleTimer);
|
||||
record.idleTimer = null;
|
||||
}
|
||||
|
||||
export function normalizeAdapterManagedRuntimeServices(input: {
|
||||
adapterType: string;
|
||||
runId: string;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
reports: AdapterRuntimeServiceReport[];
|
||||
now?: Date;
|
||||
}): RuntimeServiceRef[] {
|
||||
const nowIso = (input.now ?? new Date()).toISOString();
|
||||
return input.reports.map((report) => {
|
||||
const scopeType = report.scopeType ?? "run";
|
||||
const scopeId =
|
||||
report.scopeId ??
|
||||
(scopeType === "project_workspace"
|
||||
? input.workspace.workspaceId
|
||||
: scopeType === "execution_workspace"
|
||||
? input.workspace.cwd
|
||||
: scopeType === "agent"
|
||||
? input.agent.id
|
||||
: input.runId) ??
|
||||
null;
|
||||
const serviceName = asString(report.serviceName, "").trim() || "service";
|
||||
const status = report.status ?? "running";
|
||||
const lifecycle = report.lifecycle ?? "ephemeral";
|
||||
const healthStatus =
|
||||
report.healthStatus ??
|
||||
(status === "running" ? "healthy" : status === "failed" ? "unhealthy" : "unknown");
|
||||
return {
|
||||
id: stableRuntimeServiceId({
|
||||
adapterType: input.adapterType,
|
||||
runId: input.runId,
|
||||
scopeType,
|
||||
scopeId,
|
||||
serviceName,
|
||||
reportId: report.id ?? null,
|
||||
providerRef: report.providerRef ?? null,
|
||||
reuseKey: report.reuseKey ?? null,
|
||||
}),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: report.projectId ?? input.workspace.projectId,
|
||||
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
|
||||
issueId: report.issueId ?? input.issue?.id ?? null,
|
||||
serviceName,
|
||||
status,
|
||||
lifecycle,
|
||||
scopeType,
|
||||
scopeId,
|
||||
reuseKey: report.reuseKey ?? null,
|
||||
command: report.command ?? null,
|
||||
cwd: report.cwd ?? null,
|
||||
port: report.port ?? null,
|
||||
url: report.url ?? null,
|
||||
provider: "adapter_managed",
|
||||
providerRef: report.providerRef ?? null,
|
||||
ownerAgentId: report.ownerAgentId ?? input.agent.id,
|
||||
startedByRunId: input.runId,
|
||||
lastUsedAt: nowIso,
|
||||
startedAt: nowIso,
|
||||
stoppedAt: status === "running" || status === "starting" ? null : nowIso,
|
||||
stopPolicy: report.stopPolicy ?? null,
|
||||
healthStatus,
|
||||
reused: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function startLocalRuntimeService(input: {
|
||||
db?: Db;
|
||||
runId: string;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
adapterEnv: Record<string, string>;
|
||||
service: Record<string, unknown>;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
reuseKey: string | null;
|
||||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
}): Promise<RuntimeServiceRecord> {
|
||||
const serviceName = asString(input.service.name, "service");
|
||||
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
||||
const command = asString(input.service.command, "");
|
||||
if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`);
|
||||
const serviceCwdTemplate = asString(input.service.cwd, ".");
|
||||
const portConfig = parseObject(input.service.port);
|
||||
const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null;
|
||||
const envConfig = parseObject(input.service.env);
|
||||
const templateData = buildTemplateData({
|
||||
workspace: input.workspace,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
adapterEnv: input.adapterEnv,
|
||||
port,
|
||||
});
|
||||
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
||||
const env: Record<string, string> = { ...process.env, ...input.adapterEnv } as Record<string, string>;
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") {
|
||||
env[key] = renderTemplate(value, templateData);
|
||||
}
|
||||
}
|
||||
if (port) {
|
||||
const portEnvKey = asString(portConfig.envKey, "PORT");
|
||||
env[portEnvKey] = String(port);
|
||||
}
|
||||
const shell = process.env.SHELL?.trim() || "/bin/sh";
|
||||
const child = spawn(shell, ["-lc", command], {
|
||||
cwd: serviceCwd,
|
||||
env,
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderrExcerpt = "";
|
||||
let stdoutExcerpt = "";
|
||||
child.stdout?.on("data", async (chunk) => {
|
||||
const text = String(chunk);
|
||||
stdoutExcerpt = (stdoutExcerpt + text).slice(-4096);
|
||||
if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`);
|
||||
});
|
||||
child.stderr?.on("data", async (chunk) => {
|
||||
const text = String(chunk);
|
||||
stderrExcerpt = (stderrExcerpt + text).slice(-4096);
|
||||
if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`);
|
||||
});
|
||||
|
||||
const expose = parseObject(input.service.expose);
|
||||
const readiness = parseObject(input.service.readiness);
|
||||
const urlTemplate =
|
||||
asString(expose.urlTemplate, "") ||
|
||||
asString(readiness.urlTemplate, "");
|
||||
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
|
||||
|
||||
try {
|
||||
await waitForReadiness({ service: input.service, url });
|
||||
} catch (err) {
|
||||
child.kill("SIGTERM");
|
||||
throw new Error(
|
||||
`Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: input.agent.companyId,
|
||||
projectId: input.workspace.projectId,
|
||||
projectWorkspaceId: input.workspace.workspaceId,
|
||||
issueId: input.issue?.id ?? null,
|
||||
serviceName,
|
||||
status: "running",
|
||||
lifecycle,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
reuseKey: input.reuseKey,
|
||||
command,
|
||||
cwd: serviceCwd,
|
||||
port,
|
||||
url,
|
||||
provider: "local_process",
|
||||
providerRef: child.pid ? String(child.pid) : null,
|
||||
ownerAgentId: input.agent.id,
|
||||
startedByRunId: input.runId,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: new Date().toISOString(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: parseObject(input.service.stopPolicy),
|
||||
healthStatus: "healthy",
|
||||
reused: false,
|
||||
db: input.db,
|
||||
child,
|
||||
leaseRunIds: new Set([input.runId]),
|
||||
idleTimer: null,
|
||||
envFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleIdleStop(record: RuntimeServiceRecord) {
|
||||
clearIdleTimer(record);
|
||||
const stopType = asString(record.stopPolicy?.type, "manual");
|
||||
if (stopType !== "idle_timeout") return;
|
||||
const idleSeconds = Math.max(1, asNumber(record.stopPolicy?.idleSeconds, 1800));
|
||||
record.idleTimer = setTimeout(() => {
|
||||
stopRuntimeService(record.id).catch(() => undefined);
|
||||
}, idleSeconds * 1000);
|
||||
}
|
||||
|
||||
async function stopRuntimeService(serviceId: string) {
|
||||
const record = runtimeServicesById.get(serviceId);
|
||||
if (!record) return;
|
||||
clearIdleTimer(record);
|
||||
record.status = "stopped";
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
record.stoppedAt = new Date().toISOString();
|
||||
if (record.child && !record.child.killed) {
|
||||
record.child.kill("SIGTERM");
|
||||
}
|
||||
runtimeServicesById.delete(serviceId);
|
||||
if (record.reuseKey) {
|
||||
runtimeServicesByReuseKey.delete(record.reuseKey);
|
||||
}
|
||||
await persistRuntimeServiceRecord(record.db, record);
|
||||
}
|
||||
|
||||
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
|
||||
record.db = db;
|
||||
runtimeServicesById.set(record.id, record);
|
||||
if (record.reuseKey) {
|
||||
runtimeServicesByReuseKey.set(record.reuseKey, record.id);
|
||||
}
|
||||
|
||||
record.child?.on("exit", (code, signal) => {
|
||||
const current = runtimeServicesById.get(record.id);
|
||||
if (!current) return;
|
||||
clearIdleTimer(current);
|
||||
current.status = code === 0 || signal === "SIGTERM" ? "stopped" : "failed";
|
||||
current.healthStatus = current.status === "failed" ? "unhealthy" : "unknown";
|
||||
current.lastUsedAt = new Date().toISOString();
|
||||
current.stoppedAt = new Date().toISOString();
|
||||
runtimeServicesById.delete(current.id);
|
||||
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
|
||||
runtimeServicesByReuseKey.delete(current.reuseKey);
|
||||
}
|
||||
void persistRuntimeServiceRecord(db, current);
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureRuntimeServicesForRun(input: {
|
||||
db?: Db;
|
||||
runId: string;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
config: Record<string, unknown>;
|
||||
adapterEnv: Record<string, string>;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}): Promise<RuntimeServiceRef[]> {
|
||||
const runtime = parseObject(input.config.workspaceRuntime);
|
||||
const rawServices = Array.isArray(runtime.services)
|
||||
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
|
||||
: [];
|
||||
const acquiredServiceIds: string[] = [];
|
||||
const refs: RuntimeServiceRef[] = [];
|
||||
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
|
||||
|
||||
try {
|
||||
for (const service of rawServices) {
|
||||
const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
|
||||
const { scopeType, scopeId } = resolveServiceScopeId({
|
||||
service,
|
||||
workspace: input.workspace,
|
||||
issue: input.issue,
|
||||
runId: input.runId,
|
||||
agent: input.agent,
|
||||
});
|
||||
const envConfig = parseObject(service.env);
|
||||
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
|
||||
const serviceName = asString(service.name, "service");
|
||||
const reuseKey =
|
||||
lifecycle === "shared"
|
||||
? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":")
|
||||
: null;
|
||||
|
||||
if (reuseKey) {
|
||||
const existingId = runtimeServicesByReuseKey.get(reuseKey);
|
||||
const existing = existingId ? runtimeServicesById.get(existingId) : null;
|
||||
if (existing && existing.status === "running") {
|
||||
existing.leaseRunIds.add(input.runId);
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
existing.stoppedAt = null;
|
||||
clearIdleTimer(existing);
|
||||
await persistRuntimeServiceRecord(input.db, existing);
|
||||
acquiredServiceIds.push(existing.id);
|
||||
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const record = await startLocalRuntimeService({
|
||||
db: input.db,
|
||||
runId: input.runId,
|
||||
agent: input.agent,
|
||||
issue: input.issue,
|
||||
workspace: input.workspace,
|
||||
adapterEnv: input.adapterEnv,
|
||||
service,
|
||||
onLog: input.onLog,
|
||||
reuseKey,
|
||||
scopeType,
|
||||
scopeId,
|
||||
});
|
||||
registerRuntimeService(input.db, record);
|
||||
await persistRuntimeServiceRecord(input.db, record);
|
||||
acquiredServiceIds.push(record.id);
|
||||
refs.push(toRuntimeServiceRef(record));
|
||||
}
|
||||
} catch (err) {
|
||||
await releaseRuntimeServicesForRun(input.runId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export async function releaseRuntimeServicesForRun(runId: string) {
|
||||
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
|
||||
runtimeServiceLeasesByRun.delete(runId);
|
||||
for (const serviceId of acquired) {
|
||||
const record = runtimeServicesById.get(serviceId);
|
||||
if (!record) continue;
|
||||
record.leaseRunIds.delete(runId);
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual");
|
||||
await persistRuntimeServiceRecord(record.db, record);
|
||||
if (record.leaseRunIds.size === 0) {
|
||||
if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") {
|
||||
await stopRuntimeService(serviceId);
|
||||
continue;
|
||||
}
|
||||
scheduleIdleStop(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
projectWorkspaceIds: string[],
|
||||
) {
|
||||
if (projectWorkspaceIds.length === 0) return new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.companyId, companyId),
|
||||
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
|
||||
const grouped = new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.projectWorkspaceId) continue;
|
||||
const existing = grouped.get(row.projectWorkspaceId);
|
||||
if (existing) existing.push(row);
|
||||
else grouped.set(row.projectWorkspaceId, [row]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
||||
const staleRows = await db
|
||||
.select({ id: workspaceRuntimeServices.id })
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
);
|
||||
|
||||
if (staleRows.length === 0) return { reconciled: 0 };
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(workspaceRuntimeServices)
|
||||
.set({
|
||||
status: "stopped",
|
||||
healthStatus: "unknown",
|
||||
stoppedAt: now,
|
||||
lastUsedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||
),
|
||||
);
|
||||
|
||||
return { reconciled: staleRows.length };
|
||||
}
|
||||
|
||||
export async function persistAdapterManagedRuntimeServices(input: {
|
||||
db: Db;
|
||||
adapterType: string;
|
||||
runId: string;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
reports: AdapterRuntimeServiceReport[];
|
||||
}) {
|
||||
const refs = normalizeAdapterManagedRuntimeServices(input);
|
||||
if (refs.length === 0) return refs;
|
||||
|
||||
const existingRows = await input.db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(inArray(workspaceRuntimeServices.id, refs.map((ref) => ref.id)));
|
||||
const existingById = new Map(existingRows.map((row) => [row.id, row]));
|
||||
|
||||
for (const ref of refs) {
|
||||
const existing = existingById.get(ref.id);
|
||||
const startedAt = existing?.startedAt ?? new Date(ref.startedAt);
|
||||
const createdAt = existing?.createdAt ?? new Date();
|
||||
await input.db
|
||||
.insert(workspaceRuntimeServices)
|
||||
.values({
|
||||
id: ref.id,
|
||||
companyId: ref.companyId,
|
||||
projectId: ref.projectId,
|
||||
projectWorkspaceId: ref.projectWorkspaceId,
|
||||
issueId: ref.issueId,
|
||||
scopeType: ref.scopeType,
|
||||
scopeId: ref.scopeId,
|
||||
serviceName: ref.serviceName,
|
||||
status: ref.status,
|
||||
lifecycle: ref.lifecycle,
|
||||
reuseKey: ref.reuseKey,
|
||||
command: ref.command,
|
||||
cwd: ref.cwd,
|
||||
port: ref.port,
|
||||
url: ref.url,
|
||||
provider: ref.provider,
|
||||
providerRef: ref.providerRef,
|
||||
ownerAgentId: ref.ownerAgentId,
|
||||
startedByRunId: ref.startedByRunId,
|
||||
lastUsedAt: new Date(ref.lastUsedAt),
|
||||
startedAt,
|
||||
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
||||
stopPolicy: ref.stopPolicy,
|
||||
healthStatus: ref.healthStatus,
|
||||
createdAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: workspaceRuntimeServices.id,
|
||||
set: {
|
||||
projectId: ref.projectId,
|
||||
projectWorkspaceId: ref.projectWorkspaceId,
|
||||
issueId: ref.issueId,
|
||||
scopeType: ref.scopeType,
|
||||
scopeId: ref.scopeId,
|
||||
serviceName: ref.serviceName,
|
||||
status: ref.status,
|
||||
lifecycle: ref.lifecycle,
|
||||
reuseKey: ref.reuseKey,
|
||||
command: ref.command,
|
||||
cwd: ref.cwd,
|
||||
port: ref.port,
|
||||
url: ref.url,
|
||||
provider: ref.provider,
|
||||
providerRef: ref.providerRef,
|
||||
ownerAgentId: ref.ownerAgentId,
|
||||
startedByRunId: ref.startedByRunId,
|
||||
lastUsedAt: new Date(ref.lastUsedAt),
|
||||
startedAt,
|
||||
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
||||
stopPolicy: ref.stopPolicy,
|
||||
healthStatus: ref.healthStatus,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function buildWorkspaceReadyComment(input: {
|
||||
workspace: RealizedExecutionWorkspace;
|
||||
runtimeServices: RuntimeServiceRef[];
|
||||
}) {
|
||||
const lines = ["## Workspace Ready", ""];
|
||||
lines.push(`- Strategy: \`${input.workspace.strategy}\``);
|
||||
if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``);
|
||||
lines.push(`- CWD: \`${input.workspace.cwd}\``);
|
||||
if (input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd) {
|
||||
lines.push(`- Worktree: \`${input.workspace.worktreePath}\``);
|
||||
}
|
||||
for (const service of input.runtimeServices) {
|
||||
const detail = service.url ? `${service.serviceName}: ${service.url}` : `${service.serviceName}: running`;
|
||||
const suffix = service.reused ? " (reused)" : "";
|
||||
lines.push(`- Service: ${detail}${suffix}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -111,6 +111,7 @@ function boardRoutes() {
|
||||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||
@@ -225,6 +226,7 @@ export function App() {
|
||||
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
{boardRoutes()}
|
||||
</Route>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||
|
||||
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";
|
||||
@@ -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.";
|
||||
|
||||
export function ClaudeLocalConfigFields({
|
||||
mode,
|
||||
isCreate,
|
||||
adapterType,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
models,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
<>
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</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,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||
|
||||
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";
|
||||
@@ -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.";
|
||||
|
||||
export function CodexLocalConfigFields({
|
||||
mode,
|
||||
isCreate,
|
||||
adapterType,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
models,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const bypassEnabled =
|
||||
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
|
||||
@@ -81,6 +85,17 @@ export function CodexLocalConfigFields({
|
||||
: 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,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import {
|
||||
PayloadTemplateJsonField,
|
||||
RuntimeServicesJsonField,
|
||||
} from "../runtime-json-fields";
|
||||
|
||||
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";
|
||||
@@ -112,6 +116,22 @@ export function OpenClawGatewayConfigFields({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<PayloadTemplateJsonField
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
/>
|
||||
|
||||
<RuntimeServicesJsonField
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
/>
|
||||
|
||||
{!isCreate && (
|
||||
<>
|
||||
<Field label="Paperclip API URL override">
|
||||
|
||||
115
ui/src/adapters/runtime-json-fields.tsx
Normal file
115
ui/src/adapters/runtime-json-fields.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AdapterConfigFieldsProps } from "./types";
|
||||
import { Field, help } from "../components/agent-config-primitives";
|
||||
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -176,6 +176,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
const project = orderedProjects.find((p) => p.id === id);
|
||||
return project?.name ?? id.slice(0, 8);
|
||||
};
|
||||
const currentProject = issue.projectId
|
||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? 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) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
@@ -402,7 +412,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",
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||
onClick={() => {
|
||||
onUpdate({ projectId: null, executionWorkspaceSettings: null });
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
@@ -419,7 +432,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",
|
||||
p.id === issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
projectId: p.id,
|
||||
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
|
||||
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
|
||||
: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
@@ -504,6 +525,42 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
{projectContent}
|
||||
</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 && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
|
||||
@@ -65,7 +65,7 @@ interface IssueDraft {
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeChrome: boolean;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
@@ -99,7 +99,6 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
chrome: boolean;
|
||||
useProjectWorkspace: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
||||
@@ -127,9 +126,6 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
overrides.adapterConfig = adapterConfig;
|
||||
}
|
||||
if (!input.useProjectWorkspace) {
|
||||
overrides.useProjectWorkspace = false;
|
||||
}
|
||||
return Object.keys(overrides).length > 0 ? overrides : null;
|
||||
}
|
||||
|
||||
@@ -180,10 +176,11 @@ export function NewIssueDialog() {
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||
|
||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||
@@ -300,7 +297,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
useIsolatedExecutionWorkspace,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
@@ -312,7 +309,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
assigneeUseProjectWorkspace,
|
||||
useIsolatedExecutionWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
]);
|
||||
@@ -321,6 +318,7 @@ export function NewIssueDialog() {
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
setDialogCompanyId(selectedCompanyId);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (newIssueDefaults.title) {
|
||||
@@ -333,7 +331,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
} else if (draft && draft.title.trim()) {
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
@@ -344,7 +342,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
@@ -353,7 +351,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
@@ -363,7 +361,6 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,10 +393,11 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
@@ -410,7 +408,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -426,8 +424,14 @@ export function NewIssueDialog() {
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
chrome: assigneeChrome,
|
||||
useProjectWorkspace: assigneeUseProjectWorkspace,
|
||||
});
|
||||
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy;
|
||||
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
|
||||
? {
|
||||
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
|
||||
}
|
||||
: null;
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
title: title.trim(),
|
||||
@@ -437,6 +441,7 @@ export function NewIssueDialog() {
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,6 +472,8 @@ export function NewIssueDialog() {
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const assigneeOptionsTitle =
|
||||
assigneeAdapterType === "claude_local"
|
||||
? "Claude options"
|
||||
@@ -503,6 +510,26 @@ export function NewIssueDialog() {
|
||||
})),
|
||||
[orderedProjects],
|
||||
);
|
||||
|
||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||
setProjectId(nextProjectId);
|
||||
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
||||
const policy = nextProject?.executionWorkspacePolicy;
|
||||
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(project.executionWorkspacePolicy?.enabled && project.executionWorkspacePolicy.defaultMode === "isolated"),
|
||||
);
|
||||
}, [newIssueOpen, orderedProjects, projectId]);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() => {
|
||||
return [...(assigneeAdapterModels ?? [])]
|
||||
@@ -705,7 +732,7 @@ export function NewIssueDialog() {
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
onChange={setProjectId}
|
||||
onChange={handleProjectChange}
|
||||
onConfirm={() => {
|
||||
descriptionEditorRef.current?.focus();
|
||||
}}
|
||||
@@ -740,6 +767,34 @@ export function NewIssueDialog() {
|
||||
</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 && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<button
|
||||
@@ -800,23 +855,6 @@ export function NewIssueDialog() {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -11,9 +11,10 @@ interface PageTabBarProps {
|
||||
items: PageTabItem[];
|
||||
value?: string;
|
||||
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();
|
||||
|
||||
if (isMobile && value !== undefined && onValueChange) {
|
||||
@@ -33,7 +34,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsList variant="line">
|
||||
<TabsList variant="line" className={align === "start" ? "justify-start" : undefined}>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
||||
@@ -13,8 +13,10 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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 { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
|
||||
const PROJECT_STATUSES = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
@@ -27,15 +29,84 @@ const PROJECT_STATUSES = [
|
||||
interface ProjectPropertiesProps {
|
||||
project: Project;
|
||||
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";
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -76,15 +147,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 queryClient = useQueryClient();
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
|
||||
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
|
||||
const [workspaceCwd, setWorkspaceCwd] = useState("");
|
||||
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
|
||||
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({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
@@ -106,6 +187,16 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
|
||||
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
|
||||
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 = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
@@ -136,16 +227,29 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
});
|
||||
|
||||
const removeGoal = (goalId: string) => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
if (!onUpdate && !onFieldUpdate) return;
|
||||
commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
};
|
||||
|
||||
const addGoal = (goalId: string) => {
|
||||
if (!onUpdate || linkedGoalIds.includes(goalId)) return;
|
||||
onUpdate({ goalIds: [...linkedGoalIds, goalId] });
|
||||
if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
|
||||
commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
|
||||
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 isGitHubRepoUrl = (value: string) => {
|
||||
@@ -254,13 +358,46 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
{onUpdate ? (
|
||||
<div>
|
||||
<div className="space-y-1 pb-4">
|
||||
<PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
|
||||
{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
|
||||
status={project.status}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
onChange={(status) => commitField("status", { status })}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={project.status} />
|
||||
@@ -271,82 +408,87 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<div className="py-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">Goals</span>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{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) => (
|
||||
<span
|
||||
key={goal.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
<PropertyRow
|
||||
label={<FieldLabel label="Goals" state={fieldState("goals")} />}
|
||||
alignStart
|
||||
valueClassName="space-y-2"
|
||||
>
|
||||
{linkedGoals.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{linkedGoals.map((goal) => (
|
||||
<span
|
||||
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">
|
||||
{goal.title}
|
||||
</Link>
|
||||
{onUpdate && (
|
||||
<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>
|
||||
)}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</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 && (
|
||||
<PropertyRow label="Target Date">
|
||||
<PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
|
||||
<span className="text-sm">{formatDate(project.targetDate)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div className="space-y-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Workspaces</span>
|
||||
<Tooltip>
|
||||
@@ -407,6 +549,51 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
</Button>
|
||||
</div>
|
||||
) : 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>
|
||||
@@ -518,14 +705,196 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<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>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Runtime services stay under Paperclip control and are not configured here yet.
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,12 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
envBindings: {},
|
||||
url: "",
|
||||
bootstrapPrompt: "",
|
||||
payloadTemplateJson: "",
|
||||
workspaceStrategyType: "project_primary",
|
||||
workspaceBaseRef: "",
|
||||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 80,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
|
||||
@@ -33,12 +33,19 @@ export const help: Record<string, string> = {
|
||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||
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.",
|
||||
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).",
|
||||
args: "Command-line arguments, 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.",
|
||||
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.",
|
||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
||||
intervalSec: "Seconds between automatic heartbeat invocations.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -14,6 +14,7 @@ import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||
import { getUIAdapter, buildTranscript } 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 { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -53,7 +55,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||
@@ -173,12 +174,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "overview" | "configure" | "runs";
|
||||
type AgentDetailView = "dashboard" | "configuration" | "runs";
|
||||
|
||||
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;
|
||||
return "overview";
|
||||
return "dashboard";
|
||||
}
|
||||
|
||||
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
||||
@@ -304,17 +305,18 @@ export function AgentDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
if (routeAgentRef === canonicalAgentRef) return;
|
||||
if (urlRunId) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
if (routeAgentRef !== canonicalAgentRef) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (urlTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${urlTab}`, { replace: true });
|
||||
const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate(`/agents/${canonicalAgentRef}`, { replace: true });
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, navigate]);
|
||||
}, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.companyId || agent.companyId === selectedCompanyId) return;
|
||||
@@ -397,17 +399,19 @@ export function AgentDetail() {
|
||||
{ label: "Agents", href: "/agents" },
|
||||
];
|
||||
const agentName = agent?.name ?? routeAgentRef ?? "Agent";
|
||||
if (activeView === "overview" && !urlRunId) {
|
||||
if (activeView === "dashboard" && !urlRunId) {
|
||||
crumbs.push({ label: agentName });
|
||||
} else {
|
||||
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}` });
|
||||
crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` });
|
||||
if (urlRunId) {
|
||||
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||
} else if (activeView === "configure") {
|
||||
crumbs.push({ label: "Configure" });
|
||||
} else if (activeView === "configuration") {
|
||||
crumbs.push({ label: "Configuration" });
|
||||
} else if (activeView === "runs") {
|
||||
crumbs.push({ label: "Runs" });
|
||||
} else {
|
||||
crumbs.push({ label: "Dashboard" });
|
||||
}
|
||||
}
|
||||
setBreadcrumbs(crumbs);
|
||||
@@ -416,7 +420,7 @@ export function AgentDetail() {
|
||||
useEffect(() => {
|
||||
closePanel();
|
||||
return () => closePanel();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [closePanel]);
|
||||
|
||||
useBeforeUnload(
|
||||
useCallback((event) => {
|
||||
@@ -429,8 +433,11 @@ export function AgentDetail() {
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!agent) return null;
|
||||
if (!urlRunId && !urlTab) {
|
||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const showConfigActionBar = activeView === "configure" && configDirty;
|
||||
const showConfigActionBar = activeView === "configuration" && configDirty;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||
@@ -514,16 +521,6 @@ export function AgentDetail() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
@@ -559,6 +556,22 @@ export function AgentDetail() {
|
||||
</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>}
|
||||
{isPendingApproval && (
|
||||
<p className="text-sm text-amber-500">
|
||||
@@ -623,20 +636,18 @@ export function AgentDetail() {
|
||||
)}
|
||||
|
||||
{/* View content */}
|
||||
{activeView === "overview" && (
|
||||
{activeView === "dashboard" && (
|
||||
<AgentOverview
|
||||
agent={agent}
|
||||
runs={heartbeats ?? []}
|
||||
assignedIssues={assignedIssues}
|
||||
runtimeState={runtimeState}
|
||||
reportsToAgent={reportsToAgent ?? null}
|
||||
directReports={directReports}
|
||||
agentId={agent.id}
|
||||
agentRouteId={canonicalAgentRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "configure" && (
|
||||
{activeView === "configuration" && (
|
||||
<AgentConfigurePage
|
||||
agent={agent}
|
||||
agentId={agent.id}
|
||||
@@ -750,8 +761,6 @@ function AgentOverview({
|
||||
runs,
|
||||
assignedIssues,
|
||||
runtimeState,
|
||||
reportsToAgent,
|
||||
directReports,
|
||||
agentId,
|
||||
agentRouteId,
|
||||
}: {
|
||||
@@ -759,8 +768,6 @@ function AgentOverview({
|
||||
runs: HeartbeatRun[];
|
||||
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
|
||||
runtimeState?: AgentRuntimeState;
|
||||
reportsToAgent: Agent | null;
|
||||
directReports: Agent[];
|
||||
agentId: string;
|
||||
agentRouteId: string;
|
||||
}) {
|
||||
@@ -820,131 +827,6 @@ function AgentOverview({
|
||||
<h3 className="text-sm font-medium">Costs</h3>
|
||||
<CostsSection runtimeState={runtimeState} runs={runs} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
|
||||
@@ -11,20 +11,18 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
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 { StatusBadge } from "../components/StatusBadge";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { projectRouteRef, cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectTab = "overview" | "list";
|
||||
type ProjectTab = "overview" | "list" | "configuration";
|
||||
|
||||
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
||||
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;
|
||||
const tab = segments[projectsIdx + 2];
|
||||
if (tab === "overview") return "overview";
|
||||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "issues") return "list";
|
||||
return null;
|
||||
}
|
||||
@@ -198,12 +197,14 @@ export function ProjectDetail() {
|
||||
filter?: string;
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
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 routeCompanyId = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -264,6 +265,10 @@ export function ProjectDetail() {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
@@ -276,11 +281,52 @@ export function ProjectDetail() {
|
||||
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
|
||||
}
|
||||
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
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
@@ -294,6 +340,8 @@ export function ProjectDetail() {
|
||||
const handleTabChange = (tab: ProjectTab) => {
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||
} else {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues`);
|
||||
}
|
||||
@@ -314,54 +362,21 @@ export function ProjectDetail() {
|
||||
as="h2"
|
||||
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>
|
||||
|
||||
{/* Top-level project tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm font-medium transition-colors border-b-2 ${
|
||||
activeTab === "overview"
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleTabChange("overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<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>
|
||||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab ?? "list"}
|
||||
onValueChange={(value) => handleTabChange(value as ProjectTab)}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === "overview" && (
|
||||
<OverviewContent
|
||||
project={project}
|
||||
@@ -377,19 +392,16 @@ export function ProjectDetail() {
|
||||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||
)}
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-sm">Properties</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 pb-4">
|
||||
<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{activeTab === "configuration" && (
|
||||
<div className="max-w-4xl">
|
||||
<ProjectProperties
|
||||
project={project}
|
||||
onUpdate={(data) => updateProject.mutate(data)}
|
||||
onFieldUpdate={updateProjectField}
|
||||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user