mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-07 15:42:06 +02:00
Compare commits
17 Commits
feature/wo
...
@paperclip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c672b71f7f | ||
|
|
01c5a6f198 | ||
|
|
64f5c3f837 | ||
|
|
c62266aa6a | ||
|
|
5dd1e6335a | ||
|
|
469bfe3953 | ||
|
|
d20341c797 | ||
|
|
756ddb6cf7 | ||
|
|
200dd66f63 | ||
|
|
9859bac440 | ||
|
|
8d6b20b47b | ||
|
|
a418106005 | ||
|
|
d9ba4790e9 | ||
|
|
af0e05f38c | ||
|
|
f4a9788f2d | ||
|
|
d7b98a72b4 | ||
|
|
57406dbc90 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -32,7 +32,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
@@ -115,9 +115,9 @@ jobs:
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable release commit and tag
|
||||
- name: Push stable release branch commit and tag
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
run: git push origin HEAD:master --follow-tags
|
||||
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
|
||||
@@ -32,8 +32,10 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" &
|
||||
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
@@ -49,4 +51,5 @@ ENV NODE_ENV=production \
|
||||
VOLUME ["/paperclip"]
|
||||
EXPOSE 3100
|
||||
|
||||
USER node
|
||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
|
||||
}
|
||||
|
||||
const db = createDb(dbUrl);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
try {
|
||||
const existingAdminCount = await db
|
||||
.select()
|
||||
@@ -122,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
} catch (err) {
|
||||
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
|
||||
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,40 +66,28 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
|
||||
// 5. Storage check
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
|
||||
// 6. Database check
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -107,13 +95,10 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -135,9 +120,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -145,7 +130,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return false;
|
||||
if (p.isCancel(answer)) return;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -153,30 +138,10 @@ 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 } {
|
||||
|
||||
@@ -86,11 +86,29 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
await bootstrapCeoInvite({
|
||||
config: configPath,
|
||||
dbUrl: startedServer.databaseUrl,
|
||||
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
|
||||
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBootstrapInviteBaseUrl(
|
||||
config: PaperclipConfig,
|
||||
startedServer: StartedServer,
|
||||
): string {
|
||||
const explicitBaseUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
|
||||
|
||||
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
|
||||
return explicitBaseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
return startedServer.apiUrl.replace(/\/api$/, "");
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
if (err.message && err.message.trim().length > 0) return err.message;
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
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,17 +25,13 @@ function parseEnvFile(contents: string) {
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
...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);
|
||||
}
|
||||
@@ -86,33 +82,13 @@ 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 });
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
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,8 +16,6 @@ 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 =
|
||||
@@ -35,7 +33,6 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -135,7 +132,6 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -124,50 +124,6 @@ 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:
|
||||
|
||||
@@ -122,5 +122,7 @@ Notes:
|
||||
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
|
||||
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
|
||||
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
|
||||
- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:<HOST_PORT>` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`.
|
||||
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
|
||||
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
|
||||
- The image definition is in `Dockerfile.onboard-smoke`.
|
||||
|
||||
@@ -8,10 +8,11 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This
|
||||
|
||||
Use these scripts instead of older one-off publish commands:
|
||||
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
|
||||
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
|
||||
|
||||
## Why the CLI needs special packaging
|
||||
|
||||
@@ -87,7 +88,7 @@ This means:
|
||||
|
||||
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
|
||||
|
||||
The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps.
|
||||
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
|
||||
|
||||
## Rollback model
|
||||
|
||||
@@ -109,7 +110,7 @@ Recommended CI release setup:
|
||||
|
||||
- use npm trusted publishing via GitHub OIDC
|
||||
- require approval through the `npm-release` environment
|
||||
- run releases from `master`
|
||||
- run releases from `release/X.Y.Z`
|
||||
- use canary first, then stable
|
||||
|
||||
## Related Files
|
||||
|
||||
594
doc/RELEASING.md
594
doc/RELEASING.md
@@ -2,260 +2,138 @@
|
||||
|
||||
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
|
||||
|
||||
This document is intentionally practical:
|
||||
The release model is branch-driven:
|
||||
|
||||
- TL;DR command sequences are at the top.
|
||||
- Detailed checklists come next.
|
||||
- Motivation, failure handling, and rollback playbooks follow after that.
|
||||
1. Start a release train on `release/X.Y.Z`
|
||||
2. Draft the stable changelog on that branch
|
||||
3. Publish one or more canaries from that branch
|
||||
4. Publish stable from that same branch head
|
||||
5. Push the branch commit and tag
|
||||
6. Create the GitHub Release
|
||||
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
|
||||
|
||||
## Release Surfaces
|
||||
|
||||
Every Paperclip release has four separate surfaces:
|
||||
Every release has four separate surfaces:
|
||||
|
||||
1. **Verification** — the exact git SHA must pass typecheck, tests, and build.
|
||||
2. **npm** — `paperclipai` and the public workspace packages are published.
|
||||
3. **GitHub** — the stable release gets a git tag and a GitHub Release.
|
||||
4. **Website / announcements** — the stable changelog is published externally and announced.
|
||||
1. **Verification** — the exact git SHA passes typecheck, tests, and build
|
||||
2. **npm** — `paperclipai` and public workspace packages are published
|
||||
3. **GitHub** — the stable release gets a git tag and GitHub Release
|
||||
4. **Website / announcements** — the stable changelog is published externally and announced
|
||||
|
||||
Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled.
|
||||
A release is done only when all four surfaces are handled.
|
||||
|
||||
## Core Invariants
|
||||
|
||||
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
|
||||
- The release scripts must run from the matching `release/X.Y.Z` branch.
|
||||
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
|
||||
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
|
||||
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
|
||||
|
||||
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
|
||||
|
||||
## TL;DR
|
||||
|
||||
### Canary release
|
||||
### 1. Start the release train
|
||||
|
||||
Use this when you want an installable prerelease without changing `latest`.
|
||||
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
|
||||
|
||||
```bash
|
||||
# 0. Confirm master already has the CI-owned lockfile refresh merged
|
||||
# If package manifests changed recently, wait for the refresh-lockfile PR first.
|
||||
./scripts/release-start.sh patch
|
||||
```
|
||||
|
||||
# 1. Preflight the canary candidate
|
||||
./scripts/release-preflight.sh canary patch
|
||||
That script:
|
||||
|
||||
# 2. Draft or update the stable changelog for the intended stable version
|
||||
VERSION=0.2.8
|
||||
- fetches the release remote and tags
|
||||
- computes the next stable version from the latest `v*` tag
|
||||
- creates or resumes `release/X.Y.Z`
|
||||
- creates or resumes a dedicated worktree
|
||||
- pushes the branch to the remote by default
|
||||
- refuses to reuse a frozen release train
|
||||
|
||||
### 2. Draft the stable changelog
|
||||
|
||||
From the release worktree:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
# 3. Preview the canary release
|
||||
### 3. Verify and publish a canary
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary patch
|
||||
./scripts/release.sh patch --canary --dry-run
|
||||
|
||||
# 4. Publish the canary
|
||||
./scripts/release.sh patch --canary
|
||||
|
||||
# 5. Smoke test what users will actually install
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
# Users install with:
|
||||
Users install canaries with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
|
||||
- `latest` is unchanged
|
||||
- no git tag is created
|
||||
- no GitHub Release is created
|
||||
- the working tree returns to clean after the script finishes
|
||||
- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N`
|
||||
|
||||
### Stable release
|
||||
|
||||
Use this only after the canary SHA is good enough to become the public default.
|
||||
### 4. Publish stable
|
||||
|
||||
```bash
|
||||
# 0. Confirm master already has the CI-owned lockfile refresh merged
|
||||
# If package manifests changed recently, wait for the refresh-lockfile PR first.
|
||||
|
||||
# 1. Start from the vetted commit
|
||||
git checkout master
|
||||
git pull
|
||||
|
||||
# 2. Preflight the stable candidate
|
||||
./scripts/release-preflight.sh stable patch
|
||||
|
||||
# 3. Confirm the stable changelog exists
|
||||
VERSION=0.2.8
|
||||
ls "releases/v${VERSION}.md"
|
||||
|
||||
# 4. Preview the stable publish
|
||||
./scripts/release.sh patch --dry-run
|
||||
|
||||
# 5. Publish the stable release to npm and create the local release commit + tag
|
||||
./scripts/release.sh patch
|
||||
|
||||
# 6. Push the release commit and tag
|
||||
git push public-gh HEAD:master --follow-tags
|
||||
|
||||
# 7. Create or update the GitHub Release from the pushed tag
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
Result:
|
||||
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
|
||||
|
||||
- npm gets stable `X.Y.Z` under dist-tag `latest`
|
||||
- a local git commit and tag `vX.Y.Z` are created
|
||||
- after push, GitHub gets the matching Release
|
||||
- the website and announcement steps still need to be handled manually
|
||||
## Release Branches
|
||||
|
||||
### Emergency rollback
|
||||
Paperclip uses one release branch per target stable version:
|
||||
|
||||
If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix.
|
||||
- `release/0.3.0`
|
||||
- `release/0.3.1`
|
||||
- `release/1.0.0`
|
||||
|
||||
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
|
||||
|
||||
## Script Entry Points
|
||||
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
|
||||
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
|
||||
|
||||
## Detailed Workflow
|
||||
|
||||
### 1. Start or resume the release train
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
# Preview
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
|
||||
# Roll back latest for every public package
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
./scripts/release-start.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
|
||||
|
||||
### Standalone onboarding smoke
|
||||
|
||||
You already have a script for isolated onboarding verification:
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
./scripts/release-start.sh patch --dry-run
|
||||
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
|
||||
./scripts/release-start.sh patch --no-push
|
||||
```
|
||||
|
||||
This is the best existing fit when you want:
|
||||
The script is intentionally idempotent:
|
||||
|
||||
- a standalone Paperclip data dir
|
||||
- a dedicated host port
|
||||
- an end-to-end `npx paperclipai ... onboard` check
|
||||
- if `release/X.Y.Z` already exists locally, it reuses it
|
||||
- if the branch already exists on the remote, it resumes it locally
|
||||
- if the branch is already checked out in another worktree, it points you there
|
||||
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
|
||||
|
||||
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
|
||||
### 2. Write the stable changelog early
|
||||
|
||||
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-git.sh
|
||||
```
|
||||
|
||||
That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing.
|
||||
|
||||
If you want to exercise onboarding from the current committed ref in your local repo, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
```
|
||||
|
||||
This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits.
|
||||
|
||||
### GitHub Actions release
|
||||
|
||||
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens.
|
||||
|
||||
Use it from the Actions tab:
|
||||
|
||||
1. Choose `Release`
|
||||
2. Choose `channel`: `canary` or `stable`
|
||||
3. Choose `bump`: `patch`, `minor`, or `major`
|
||||
4. Choose whether this is a `dry_run`
|
||||
5. Run it from `master`
|
||||
|
||||
The workflow:
|
||||
|
||||
- reruns `typecheck`, `test:run`, and `build`
|
||||
- gates publish behind the `npm-release` environment
|
||||
- can publish canaries without touching `latest`
|
||||
- can publish stable, push the release commit and tag, and create the GitHub Release
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Before any publish
|
||||
|
||||
- [ ] The working tree is clean, including untracked files
|
||||
- [ ] The target branch and SHA are the ones you actually want to release
|
||||
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`
|
||||
- [ ] The required verification gate passed on that exact SHA
|
||||
- [ ] The bump type is correct for the user-visible impact
|
||||
- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md`
|
||||
- [ ] You know which previous stable version you would roll back to if needed
|
||||
|
||||
### Before a canary
|
||||
|
||||
- [ ] You are intentionally testing something that should be installable before it becomes default
|
||||
- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard`
|
||||
- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1`
|
||||
|
||||
### Before a stable
|
||||
|
||||
- [ ] The candidate has already passed smoke testing
|
||||
- [ ] The changelog should be the stable version only, for example `v1.2.3`
|
||||
- [ ] You are ready to push the release commit and tag immediately after npm publish
|
||||
- [ ] You are ready to create the GitHub Release immediately after the push
|
||||
- [ ] You have a post-release website / announcement plan
|
||||
|
||||
### After a stable
|
||||
|
||||
- [ ] `npm view paperclipai@latest version` matches the new stable version
|
||||
- [ ] The git tag exists on GitHub
|
||||
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
|
||||
- [ ] The website changelog is updated
|
||||
- [ ] Any announcement copy matches the shipped release, not the canary
|
||||
|
||||
## Verification Gate
|
||||
|
||||
The repository standard is:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready.
|
||||
|
||||
The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged.
|
||||
|
||||
For release work, prefer:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
```
|
||||
|
||||
That script runs the verification gate and prints the computed target versions before you publish anything.
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
### Stable versions
|
||||
|
||||
Stable releases use normal semver:
|
||||
|
||||
- `patch` for bug fixes
|
||||
- `minor` for additive features, endpoints, and additive migrations
|
||||
- `major` for destructive migrations, removed APIs, or other breaking behavior
|
||||
|
||||
### Canary versions
|
||||
|
||||
Canaries are semver prereleases of the **intended stable version**:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
- `1.2.3-canary.2`
|
||||
|
||||
That gives you three useful properties:
|
||||
|
||||
1. Users can install the prerelease explicitly with `@canary`
|
||||
2. `latest` stays safe
|
||||
3. The stable changelog can remain just `v1.2.3`
|
||||
|
||||
We do **not** create separate changelog files for canary versions.
|
||||
|
||||
Concrete example:
|
||||
|
||||
- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0`
|
||||
- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version
|
||||
|
||||
## Changelog Policy
|
||||
|
||||
The maintainer changelog source of truth is:
|
||||
Create or update:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
@@ -268,14 +146,13 @@ Recommended structure:
|
||||
- `Improvements`
|
||||
- `Fixes`
|
||||
- `Upgrade Guide` when needed
|
||||
- `Contributors` — @-mention every contributor by GitHub username (no emails)
|
||||
|
||||
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
|
||||
|
||||
## Detailed Workflow
|
||||
### 3. Run release preflight
|
||||
|
||||
### 1. Decide the bump
|
||||
|
||||
Run preflight first:
|
||||
From the `release/X.Y.Z` worktree:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
@@ -283,70 +160,54 @@ Run preflight first:
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
```
|
||||
|
||||
That command:
|
||||
The preflight script now checks all of the following before it runs the verification gate:
|
||||
|
||||
- verifies the worktree is clean, including untracked files
|
||||
- shows the last stable tag and computed next versions
|
||||
- shows the commit range since the last stable tag
|
||||
- highlights migration and breaking-change signals
|
||||
- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`
|
||||
- the worktree is clean, including untracked files
|
||||
- the current branch matches the computed `release/X.Y.Z`
|
||||
- the release train is not frozen
|
||||
- the target version is still free on npm
|
||||
- the target tag does not already exist locally or remotely
|
||||
- whether the remote release branch already exists
|
||||
- whether `releases/vX.Y.Z.md` is present
|
||||
|
||||
If you want the raw inputs separately, review the range since the last stable tag:
|
||||
Then it runs:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
|
||||
git log "${LAST_TAG}..HEAD" --oneline --no-merges
|
||||
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
|
||||
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
|
||||
git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Use the higher bump if there is any doubt.
|
||||
|
||||
### 2. Write the stable changelog first
|
||||
|
||||
Create or update:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
This is deliberate. The release notes should describe the stable story, not the canary mechanics.
|
||||
|
||||
### 3. Publish one or more canaries
|
||||
### 4. Publish one or more canaries
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --canary --dry-run
|
||||
./scripts/release.sh <patch|minor|major> --canary
|
||||
```
|
||||
|
||||
What the script does:
|
||||
Result:
|
||||
|
||||
1. Verifies the working tree is clean
|
||||
2. Computes the intended stable version from the last stable tag
|
||||
3. Computes the next canary ordinal from npm
|
||||
4. Versions the public packages to `X.Y.Z-canary.N`
|
||||
5. Builds the workspace and publishable CLI
|
||||
6. Publishes to npm under dist-tag `canary`
|
||||
7. Cleans up the temporary versioning state so your branch returns to clean
|
||||
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
|
||||
- `latest` is unchanged
|
||||
- no git tag is created
|
||||
- no GitHub Release is created
|
||||
- the worktree returns to clean after the script finishes
|
||||
|
||||
This means the script is safe to repeat as many times as needed while iterating:
|
||||
Guardrails:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
- `1.2.3-canary.2`
|
||||
- the script refuses to run from the wrong branch
|
||||
- the script refuses to publish from a frozen train
|
||||
- the canary is always derived from the next stable version
|
||||
- if the stable notes file is missing, the script warns before you forget it
|
||||
|
||||
The target stable release can still remain `1.2.3`.
|
||||
Concrete example:
|
||||
|
||||
Guardrail:
|
||||
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
|
||||
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
|
||||
|
||||
- the canary is always derived from the **next stable version**
|
||||
- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0`
|
||||
- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release
|
||||
|
||||
### 4. Smoke test the canary
|
||||
### 5. Smoke test the canary
|
||||
|
||||
Run the actual install path in Docker:
|
||||
|
||||
@@ -361,165 +222,198 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary .
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
If you want to smoke onboarding from the current codebase rather than npm, run:
|
||||
If you want to exercise onboarding from the current committed ref instead of npm, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-git.sh
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
```
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- [ ] `npx paperclipai@canary onboard` installs
|
||||
- [ ] onboarding completes without crashes
|
||||
- [ ] the server boots
|
||||
- [ ] the UI loads
|
||||
- [ ] basic company creation and dashboard load work
|
||||
- `npx paperclipai@canary onboard` installs
|
||||
- onboarding completes without crashes
|
||||
- the server boots
|
||||
- the UI loads
|
||||
- basic company creation and dashboard load work
|
||||
|
||||
### 5. Publish stable from the vetted commit
|
||||
If smoke testing fails:
|
||||
|
||||
Once the candidate SHA is good, run the stable flow on that exact commit:
|
||||
1. stop the stable release
|
||||
2. fix the issue on the same `release/X.Y.Z` branch
|
||||
3. publish another canary
|
||||
4. rerun smoke testing
|
||||
|
||||
### 6. Publish stable from the same release branch
|
||||
|
||||
Once the branch head is vetted, run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --dry-run
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
What the script does:
|
||||
Stable publish:
|
||||
|
||||
1. Verifies the working tree is clean
|
||||
2. Versions the public packages to the stable semver
|
||||
3. Builds the workspace and CLI publish bundle
|
||||
4. Publishes to npm under `latest`
|
||||
5. Restores temporary publish artifacts
|
||||
6. Creates the local release commit and git tag
|
||||
- publishes `X.Y.Z` to npm under `latest`
|
||||
- creates the local release commit
|
||||
- creates the local tag `vX.Y.Z`
|
||||
|
||||
What it does **not** do:
|
||||
Stable publish refuses to proceed if:
|
||||
|
||||
- it does not push for you
|
||||
- it does not update the website
|
||||
- it does not announce the release for you
|
||||
- the current branch is not `release/X.Y.Z`
|
||||
- the remote release branch does not exist yet
|
||||
- the stable notes file is missing
|
||||
- the target tag already exists locally or remotely
|
||||
- the stable version already exists on npm
|
||||
|
||||
### 6. Push the release and create the GitHub Release
|
||||
Those checks intentionally freeze the train after stable publish.
|
||||
|
||||
After a stable publish succeeds:
|
||||
### 7. Push the stable branch commit and tag
|
||||
|
||||
After stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD:master --follow-tags
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
The GitHub release notes come from:
|
||||
The GitHub Release notes come from:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
### 7. Complete the external surfaces
|
||||
### 8. Merge the release branch back to `master`
|
||||
|
||||
Open a PR:
|
||||
|
||||
- base: `master`
|
||||
- head: `release/X.Y.Z`
|
||||
|
||||
Merge rule:
|
||||
|
||||
- allowed: merge commit or fast-forward
|
||||
- forbidden: squash merge
|
||||
- forbidden: rebase merge
|
||||
|
||||
Post-merge verification:
|
||||
|
||||
```bash
|
||||
git fetch public-gh --tags
|
||||
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
|
||||
```
|
||||
|
||||
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
|
||||
|
||||
### 9. Finish the external surfaces
|
||||
|
||||
After GitHub is correct:
|
||||
|
||||
- publish the changelog on the website
|
||||
- write the announcement copy
|
||||
- write and send the announcement copy
|
||||
- ensure public docs and install guidance point to the stable version
|
||||
|
||||
## GitHub Actions and npm Trusted Publishing
|
||||
## GitHub Actions Release
|
||||
|
||||
If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing.
|
||||
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
Recommended setup:
|
||||
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
|
||||
|
||||
1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm
|
||||
2. Use the `npm-release` GitHub environment with required reviewers
|
||||
3. Run stable publishes from `master` only
|
||||
4. Keep the workflow manual via `workflow_dispatch`
|
||||
1. Choose `Release`
|
||||
2. Choose `channel`: `canary` or `stable`
|
||||
3. Choose `bump`: `patch`, `minor`, or `major`
|
||||
4. Choose whether this is a `dry_run`
|
||||
5. Run it from the release branch, not from `master`
|
||||
|
||||
Why this is the right shape:
|
||||
The workflow:
|
||||
|
||||
- no long-lived npm token needs to live in GitHub secrets
|
||||
- reviewers can approve the publish step at the environment gate
|
||||
- the workflow reruns verification on the release SHA before publish
|
||||
- stable and canary use the same mechanics
|
||||
- reruns `typecheck`, `test:run`, and `build`
|
||||
- gates publish behind the `npm-release` environment
|
||||
- can publish canaries without touching `latest`
|
||||
- can publish stable, push the stable branch commit and tag, and create the GitHub Release
|
||||
|
||||
It does not merge the release branch back to `master` for you.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Before any publish
|
||||
|
||||
- [ ] The release train exists on `release/X.Y.Z`
|
||||
- [ ] The working tree is clean, including untracked files
|
||||
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
|
||||
- [ ] The required verification gate passed on the exact branch head you want to publish
|
||||
- [ ] The bump type is correct for the user-visible impact
|
||||
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
|
||||
- [ ] You know which previous stable version you would roll back to if needed
|
||||
|
||||
### Before a stable
|
||||
|
||||
- [ ] The candidate has already passed smoke testing
|
||||
- [ ] The remote `release/X.Y.Z` branch exists
|
||||
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
|
||||
- [ ] You are ready to create the GitHub Release immediately after the push
|
||||
- [ ] You are ready to open the PR back to `master`
|
||||
|
||||
### After a stable
|
||||
|
||||
- [ ] `npm view paperclipai@latest version` matches the new stable version
|
||||
- [ ] The git tag exists on GitHub
|
||||
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
|
||||
- [ ] `vX.Y.Z` is reachable from `master`
|
||||
- [ ] The website changelog is updated
|
||||
- [ ] Announcement copy matches the stable release, not the canary
|
||||
|
||||
## Failure Playbooks
|
||||
|
||||
### If the canary fails before publish
|
||||
|
||||
Nothing shipped. Fix the code and rerun the canary workflow.
|
||||
|
||||
### If the canary publishes but the smoke test fails
|
||||
|
||||
Do **not** publish stable.
|
||||
Do not publish stable.
|
||||
|
||||
Instead:
|
||||
|
||||
1. Fix the issue
|
||||
2. Publish another canary
|
||||
3. Re-run smoke testing
|
||||
1. fix the issue on `release/X.Y.Z`
|
||||
2. publish another canary
|
||||
3. rerun smoke testing
|
||||
|
||||
The canary version number will increase, but the stable target version can remain the same.
|
||||
|
||||
### If the stable npm publish succeeds but push fails
|
||||
### If stable npm publish succeeds but push or GitHub release creation fails
|
||||
|
||||
This is a partial release. npm is already live.
|
||||
|
||||
Do this immediately:
|
||||
|
||||
1. Fix the git issue
|
||||
2. Push the release commit and tag from the same checkout
|
||||
3. Create the GitHub Release
|
||||
1. fix the git or GitHub issue from the same checkout
|
||||
2. push the stable branch commit and tag
|
||||
3. create the GitHub Release
|
||||
|
||||
Do **not** publish the same version again.
|
||||
Do not republish the same version.
|
||||
|
||||
### If the stable release is bad after `latest` moves
|
||||
### If `latest` is broken after stable publish
|
||||
|
||||
Use the rollback script first:
|
||||
Preview:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <last-good-version>
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
```
|
||||
|
||||
Then:
|
||||
Roll back:
|
||||
|
||||
1. open an incident note or maintainer comment
|
||||
2. fix forward on a new patch release
|
||||
3. update the changelog / release notes if the user-facing guidance changed
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
```
|
||||
|
||||
### If the GitHub Release is wrong
|
||||
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
|
||||
|
||||
Edit it by re-running:
|
||||
Then fix forward with a new patch release.
|
||||
|
||||
### If the GitHub Release notes are wrong
|
||||
|
||||
Re-run:
|
||||
|
||||
```bash
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
This updates the release notes if the GitHub Release already exists.
|
||||
|
||||
### If the website changelog is wrong
|
||||
|
||||
Fix the website independently. Do not republish npm just to repair the website surface.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
The default rollback strategy is **dist-tag rollback, then fix forward**.
|
||||
|
||||
Why:
|
||||
|
||||
- npm versions are immutable
|
||||
- users need `npx paperclipai onboard` to recover quickly
|
||||
- moving `latest` back is faster and safer than trying to delete history
|
||||
|
||||
Rollback procedure:
|
||||
|
||||
1. identify the last known good stable version
|
||||
2. run `./scripts/rollback-latest.sh <version>`
|
||||
3. verify `npm view paperclipai@latest version`
|
||||
4. fix forward with a new stable release
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release
|
||||
- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI
|
||||
If the release already exists, the script updates it.
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
|
||||
"dev:once": "node scripts/dev-runner.mjs dev",
|
||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
@@ -18,6 +18,7 @@
|
||||
"db:backup": "./scripts/backup-db.sh",
|
||||
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||
"build:npm": "./scripts/build-npm.sh",
|
||||
"release:start": "./scripts/release-start.sh",
|
||||
"release": "./scripts/release.sh",
|
||||
"release:preflight": "./scripts/release-preflight.sh",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
@@ -34,6 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -3,7 +3,6 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
|
||||
@@ -15,6 +15,11 @@ interface RunningProcess {
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
@@ -125,6 +130,78 @@ export function defaultPathForPlatform() {
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
|
||||
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
|
||||
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string) {
|
||||
try {
|
||||
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
return (await pathExists(absolute)) ? absolute : null;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
||||
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidates =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [path.join(dir, command)]
|
||||
: exts.map((ext) => path.join(dir, `${command}${ext}`))
|
||||
: [path.join(dir, command)];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<SpawnTarget> {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
const executable = resolved ?? command;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
if (/\.(cmd|bat)$/i.test(executable)) {
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
};
|
||||
}
|
||||
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
@@ -169,36 +246,12 @@ export async function ensureAbsoluteDirectory(
|
||||
}
|
||||
|
||||
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
if (resolved) return;
|
||||
if (command.includes("/") || command.includes("\\")) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
try {
|
||||
await fs.access(absolute, fsConstants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
return;
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const windowsExt = process.platform === "win32"
|
||||
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
||||
: [""];
|
||||
|
||||
for (const dir of dirs) {
|
||||
for (const ext of windowsExt) {
|
||||
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return;
|
||||
} catch {
|
||||
// continue scanning PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
@@ -220,78 +273,82 @@ export async function runChildProcess(
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
||||
const child = spawn(command, args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,27 +32,6 @@ 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;
|
||||
@@ -72,7 +51,6 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
}
|
||||
@@ -230,12 +208,6 @@ 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -25,13 +25,8 @@ 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,28 +115,14 @@ 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;
|
||||
@@ -197,9 +183,6 @@ 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;
|
||||
}
|
||||
@@ -209,24 +192,9 @@ 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,18 +50,6 @@ 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;
|
||||
@@ -82,18 +70,6 @@ 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -31,8 +31,6 @@ 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
|
||||
@@ -42,5 +40,4 @@ 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,28 +126,14 @@ 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;
|
||||
@@ -206,9 +192,6 @@ 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;
|
||||
}
|
||||
@@ -218,24 +201,9 @@ 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,18 +54,6 @@ 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;
|
||||
@@ -88,18 +76,6 @@ 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
@@ -31,7 +31,6 @@ 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)
|
||||
@@ -40,15 +39,4 @@ 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,8 +1,4 @@
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterRuntimeServiceReport,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } 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";
|
||||
@@ -415,58 +411,6 @@ 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);
|
||||
@@ -891,91 +835,6 @@ 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;
|
||||
@@ -1065,11 +924,9 @@ 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,
|
||||
@@ -1331,24 +1188,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
null;
|
||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
@@ -1364,7 +1209,6 @@ 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,17 +1,5 @@
|
||||
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;
|
||||
@@ -20,11 +8,5 @@ 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,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
@@ -9,9 +9,6 @@ export type RunDatabaseBackupOptions = {
|
||||
retentionDays: number;
|
||||
filenamePrefix?: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
includeMigrationJournal?: boolean;
|
||||
excludeTables?: string[];
|
||||
nullifyColumns?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type RunDatabaseBackupResult = {
|
||||
@@ -20,50 +17,6 @@ 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())}`;
|
||||
@@ -94,60 +47,10 @@ 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 {
|
||||
@@ -155,35 +58,13 @@ 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("");
|
||||
emitStatement("BEGIN;");
|
||||
emitStatement("SET LOCAL session_replication_role = replica;");
|
||||
emitStatement("SET LOCAL client_min_messages = warning;");
|
||||
emit("BEGIN;");
|
||||
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
|
||||
@@ -197,65 +78,23 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
for (const e of enums) {
|
||||
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
|
||||
emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
}
|
||||
if (enums.length > 0) emit("");
|
||||
|
||||
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
|
||||
// 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 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 { schema_name, tablename } of tables) {
|
||||
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
|
||||
for (const { tablename } of tables) {
|
||||
const columns = await sql<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -269,12 +108,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 = ${schema_name} AND table_name = ${tablename}
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
emit(`-- Table: ${schema_name}.${tablename}`);
|
||||
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
|
||||
emit(`-- Table: ${tablename}`);
|
||||
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
|
||||
|
||||
const colDefs: string[] = [];
|
||||
for (const col of columns) {
|
||||
@@ -310,7 +149,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 = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
GROUP BY c.conname
|
||||
`;
|
||||
for (const p of pk) {
|
||||
@@ -318,31 +157,17 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
|
||||
}
|
||||
|
||||
emit(`CREATE TABLE ${qualifiedTableName} (`);
|
||||
emit(`CREATE TABLE "${tablename}" (`);
|
||||
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 allForeignKeys = await sql<{
|
||||
const fks = 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;
|
||||
@@ -350,157 +175,137 @@ 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 tgtn ON tgtn.oid = tgt.relnamespace
|
||||
JOIN pg_namespace n ON n.oid = src.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 (
|
||||
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
|
||||
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
|
||||
`;
|
||||
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(", ");
|
||||
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(
|
||||
`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};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Unique constraints
|
||||
const allUniqueConstraints = await sql<{
|
||||
const uniques = 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 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
|
||||
WHERE n.nspname = 'public' AND c.contype = 'u'
|
||||
GROUP BY c.conname, t.relname
|
||||
ORDER BY 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(", ");
|
||||
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Indexes (non-primary, non-unique-constraint)
|
||||
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
|
||||
SELECT schemaname AS schema_name, tablename, indexdef
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE (
|
||||
schemaname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
WHERE schemaname = 'public'
|
||||
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
|
||||
SELECT conname FROM pg_constraint
|
||||
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
||||
)
|
||||
ORDER BY schemaname, tablename, indexname
|
||||
ORDER BY 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) {
|
||||
emitStatement(`${idx.indexdef};`);
|
||||
emit(`${idx.indexdef};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Dump data for each table
|
||||
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;
|
||||
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;
|
||||
|
||||
// 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 = ${schema_name} AND table_name = ${tablename}
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
|
||||
|
||||
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
|
||||
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
|
||||
|
||||
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
|
||||
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
|
||||
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
|
||||
for (const row of rows) {
|
||||
const values = row.map((rawValue: unknown, index) => {
|
||||
const columnName = cols[index]?.column_name;
|
||||
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
|
||||
const values = row.map((val: unknown) => {
|
||||
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 formatSqlLiteral(val.toISOString());
|
||||
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
|
||||
return formatSqlLiteral(String(val));
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`;
|
||||
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
|
||||
return `'${String(val).replace(/'/g, "''")}'`;
|
||||
});
|
||||
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
|
||||
emit(`INSERT INTO "${tablename}" (${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 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"});`);
|
||||
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});`);
|
||||
}
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
emitStatement("COMMIT;");
|
||||
emit("COMMIT;");
|
||||
emit("");
|
||||
|
||||
// Write the backup file
|
||||
@@ -521,36 +326,6 @@ 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,10 +10,6 @@ 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);
|
||||
}
|
||||
@@ -227,7 +223,7 @@ async function applyPendingMigrationsManually(
|
||||
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
|
||||
);
|
||||
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
try {
|
||||
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
|
||||
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
|
||||
@@ -476,7 +472,7 @@ export async function reconcilePendingMigrationHistory(
|
||||
return { repairedMigrations: [], remainingMigrations: [] };
|
||||
}
|
||||
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const repairedMigrations: string[] = [];
|
||||
|
||||
try {
|
||||
@@ -583,7 +579,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
|
||||
}
|
||||
|
||||
export async function inspectMigrations(url: string): Promise<MigrationState> {
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const availableMigrations = await listMigrationFiles();
|
||||
@@ -646,7 +642,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
|
||||
const initialState = await inspectMigrations(url);
|
||||
if (initialState.status === "upToDate") return;
|
||||
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const db = drizzlePg(sql);
|
||||
@@ -684,7 +680,7 @@ export type MigrationBootstrapResult =
|
||||
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
|
||||
|
||||
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const migrationTableSchema = await discoverMigrationTableSchema(sql);
|
||||
@@ -723,7 +719,7 @@ export async function ensurePostgresDatabase(
|
||||
throw new Error(`Unsafe database name: ${databaseName}`);
|
||||
}
|
||||
|
||||
const sql = createUtilitySql(url);
|
||||
const sql = postgres(url, { max: 1 });
|
||||
try {
|
||||
const existing = await sql<{ one: number }[]>`
|
||||
select 1 as one from pg_database where datname = ${databaseName} limit 1
|
||||
|
||||
@@ -12,10 +12,8 @@ export {
|
||||
} from "./client.js";
|
||||
export {
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
formatDatabaseBackupResult,
|
||||
type RunDatabaseBackupOptions,
|
||||
type RunDatabaseBackupResult,
|
||||
type RunDatabaseRestoreOptions,
|
||||
} from "./backup-lib.js";
|
||||
export * from "./schema/index.js";
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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");
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -183,20 +183,6 @@
|
||||
"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,7 +13,6 @@ 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,7 +40,6 @@ 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, jsonb } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
@@ -15,7 +15,6 @@ 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(),
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -77,12 +77,6 @@ export type {
|
||||
Project,
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
@@ -162,11 +156,9 @@ export {
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
|
||||
@@ -11,14 +11,6 @@ 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,7 +1,6 @@
|
||||
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;
|
||||
@@ -74,7 +73,6 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
|
||||
export interface ProjectGoalRef {
|
||||
id: string;
|
||||
@@ -16,7 +15,6 @@ export interface ProjectWorkspace {
|
||||
repoRef: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
isPrimary: boolean;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -35,7 +33,6 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
archivedAt: Date | null;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,19 +49,16 @@ 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,
|
||||
@@ -69,7 +66,6 @@ export {
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
type CheckoutIssue,
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
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(),
|
||||
@@ -38,7 +21,6 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -57,7 +39,6 @@ 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,28 +1,6 @@
|
||||
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(),
|
||||
@@ -65,7 +43,6 @@ 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(),
|
||||
};
|
||||
|
||||
@@ -79,5 +56,3 @@ 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>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## Highlights
|
||||
|
||||
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex.
|
||||
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation.
|
||||
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused.
|
||||
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. ([#62](https://github.com/paperclipai/paperclip/pull/62), [#141](https://github.com/paperclipai/paperclip/pull/141), [#240](https://github.com/paperclipai/paperclip/pull/240), [#183](https://github.com/paperclipai/paperclip/pull/183), @aaaaron, @Konan69, @richardanaya)
|
||||
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. ([#270](https://github.com/paperclipai/paperclip/pull/270))
|
||||
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. ([#196](https://github.com/paperclipai/paperclip/pull/196), @hougangdev)
|
||||
- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content.
|
||||
- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button.
|
||||
|
||||
@@ -19,29 +19,35 @@
|
||||
- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates.
|
||||
- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up.
|
||||
- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling.
|
||||
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration.
|
||||
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates.
|
||||
- **Human-readable role labels** — The agent list and properties pane show friendly role names.
|
||||
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. ([#279](https://github.com/paperclipai/paperclip/pull/279), @JasonOA888)
|
||||
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. ([#264](https://github.com/paperclipai/paperclip/pull/264), @mvanhorn)
|
||||
- **Human-readable role labels** — The agent list and properties pane show friendly role names. ([#263](https://github.com/paperclipai/paperclip/pull/263), @mvanhorn)
|
||||
- **Assignee picker sorting** — Recent selections appear first, then alphabetical.
|
||||
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile.
|
||||
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. ([#118](https://github.com/paperclipai/paperclip/pull/118), @MumuTW)
|
||||
- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance.
|
||||
- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint.
|
||||
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image.
|
||||
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants.
|
||||
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. ([#400](https://github.com/paperclipai/paperclip/pull/400), [#283](https://github.com/paperclipai/paperclip/pull/283), [#284](https://github.com/paperclipai/paperclip/pull/284), @AiMagic5000, @mingfang)
|
||||
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. ([#293](https://github.com/paperclipai/paperclip/pull/293), [#110](https://github.com/paperclipai/paperclip/pull/110), @cpfarhood, @artokun)
|
||||
- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow.
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking.
|
||||
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes.
|
||||
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler.
|
||||
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers.
|
||||
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors.
|
||||
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. ([#261](https://github.com/paperclipai/paperclip/pull/261), @mvanhorn)
|
||||
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. ([#269](https://github.com/paperclipai/paperclip/pull/269), [#78](https://github.com/paperclipai/paperclip/pull/78), @mvanhorn, @MumuTW)
|
||||
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. ([#269](https://github.com/paperclipai/paperclip/pull/269), @mvanhorn)
|
||||
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. ([#159](https://github.com/paperclipai/paperclip/pull/159), [#154](https://github.com/paperclipai/paperclip/pull/154), [#267](https://github.com/paperclipai/paperclip/pull/267), [#72](https://github.com/paperclipai/paperclip/pull/72), @Logesh-waran2003, @cschneid, @mvanhorn, @STRML)
|
||||
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. ([#266](https://github.com/paperclipai/paperclip/pull/266), @mvanhorn)
|
||||
- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output.
|
||||
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false.
|
||||
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode.
|
||||
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution.
|
||||
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures.
|
||||
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues.
|
||||
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode.
|
||||
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals.
|
||||
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
|
||||
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
|
||||
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. ([#265](https://github.com/paperclipai/paperclip/pull/265), [#413](https://github.com/paperclipai/paperclip/pull/413), @mvanhorn, @online5880)
|
||||
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. ([#376](https://github.com/paperclipai/paperclip/pull/376), @dalestubblefield)
|
||||
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. ([#260](https://github.com/paperclipai/paperclip/pull/260), @mvanhorn)
|
||||
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. ([#99](https://github.com/paperclipai/paperclip/pull/99), @zvictor)
|
||||
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. ([#262](https://github.com/paperclipai/paperclip/pull/262), [#196](https://github.com/paperclipai/paperclip/pull/196), [#423](https://github.com/paperclipai/paperclip/pull/423), @mvanhorn, @hougangdev, @RememberV)
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@aaaaron, @AiMagic5000, @artokun, @cpfarhood, @cschneid, @dalestubblefield, @Dotta, @eltociear, @fahmmin, @gsxdsm, @hougangdev, @JasonOA888, @Konan69, @Logesh-waran2003, @mingfang, @MumuTW, @mvanhorn, @numman-ali, @online5880, @RememberV, @richardanaya, @STRML, @tylerwince, @zvictor
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
|
||||
dry_run=false
|
||||
version=""
|
||||
@@ -17,7 +18,7 @@ Examples:
|
||||
./scripts/create-github-release.sh 1.2.3 --dry-run
|
||||
|
||||
Notes:
|
||||
- Run this after pushing the release commit and tag.
|
||||
- Run this after pushing the stable release branch and tag.
|
||||
- If the release already exists, this script updates its title and notes.
|
||||
EOF
|
||||
}
|
||||
@@ -52,6 +53,7 @@ fi
|
||||
|
||||
tag="v$version"
|
||||
notes_file="$REPO_ROOT/releases/${tag}.md"
|
||||
PUBLISH_REMOTE="$(resolve_release_remote)"
|
||||
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Error: gh CLI is required to create GitHub releases." >&2
|
||||
|
||||
@@ -9,14 +9,199 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
|
||||
HOST_UID="${HOST_UID:-$(id -u)}"
|
||||
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
|
||||
DOCKER_TTY_ARGS=()
|
||||
|
||||
if [[ -t 0 && -t 1 ]]; then
|
||||
DOCKER_TTY_ARGS=(-it)
|
||||
fi
|
||||
PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}"
|
||||
SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}"
|
||||
SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}"
|
||||
SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}"
|
||||
SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}"
|
||||
CONTAINER_NAME="${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}"
|
||||
LOG_PID=""
|
||||
COOKIE_JAR=""
|
||||
TMP_DIR=""
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$LOG_PID" ]]; then
|
||||
kill "$LOG_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local attempts="${2:-60}"
|
||||
local sleep_seconds="${3:-1}"
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i += 1)); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
generate_bootstrap_invite_url() {
|
||||
local bootstrap_output
|
||||
local bootstrap_status
|
||||
if bootstrap_output="$(
|
||||
docker exec \
|
||||
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
||||
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
||||
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
|
||||
-e PAPERCLIP_HOME="/paperclip" \
|
||||
"$CONTAINER_NAME" bash -lc \
|
||||
'timeout 20s npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \
|
||||
2>&1
|
||||
)"; then
|
||||
bootstrap_status=0
|
||||
else
|
||||
bootstrap_status=$?
|
||||
fi
|
||||
|
||||
if [[ $bootstrap_status -ne 0 && $bootstrap_status -ne 124 ]]; then
|
||||
echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2
|
||||
printf '%s\n' "$bootstrap_output" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local invite_url
|
||||
invite_url="$(
|
||||
printf '%s\n' "$bootstrap_output" \
|
||||
| grep -o 'https\?://[^[:space:]]*/invite/pcp_bootstrap_[[:alnum:]]*' \
|
||||
| tail -n 1
|
||||
)"
|
||||
|
||||
if [[ -z "$invite_url" ]]; then
|
||||
echo "Smoke bootstrap failed: bootstrap-ceo did not print an invite URL" >&2
|
||||
printf '%s\n' "$bootstrap_output" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $bootstrap_status -eq 124 ]]; then
|
||||
echo " Smoke bootstrap: bootstrap-ceo timed out after printing invite URL; continuing" >&2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$invite_url"
|
||||
}
|
||||
|
||||
post_json_with_cookies() {
|
||||
local url="$1"
|
||||
local body="$2"
|
||||
local output_file="$3"
|
||||
curl -sS \
|
||||
-o "$output_file" \
|
||||
-w "%{http_code}" \
|
||||
-c "$COOKIE_JAR" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: $PAPERCLIP_PUBLIC_URL" \
|
||||
-X POST \
|
||||
"$url" \
|
||||
--data "$body"
|
||||
}
|
||||
|
||||
get_with_cookies() {
|
||||
local url="$1"
|
||||
curl -fsS \
|
||||
-c "$COOKIE_JAR" \
|
||||
-b "$COOKIE_JAR" \
|
||||
-H "Accept: application/json" \
|
||||
"$url"
|
||||
}
|
||||
|
||||
sign_up_or_sign_in() {
|
||||
local signup_response="$TMP_DIR/signup.json"
|
||||
local signup_status
|
||||
signup_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/auth/sign-up/email" \
|
||||
"{\"name\":\"$SMOKE_ADMIN_NAME\",\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \
|
||||
"$signup_response")"
|
||||
if [[ "$signup_status" =~ ^2 ]]; then
|
||||
echo " Smoke bootstrap: created admin user $SMOKE_ADMIN_EMAIL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local signin_response="$TMP_DIR/signin.json"
|
||||
local signin_status
|
||||
signin_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/auth/sign-in/email" \
|
||||
"{\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \
|
||||
"$signin_response")"
|
||||
if [[ "$signin_status" =~ ^2 ]]; then
|
||||
echo " Smoke bootstrap: signed in existing admin user $SMOKE_ADMIN_EMAIL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Smoke bootstrap failed: could not sign up or sign in admin user" >&2
|
||||
echo "Sign-up response:" >&2
|
||||
cat "$signup_response" >&2 || true
|
||||
echo >&2
|
||||
echo "Sign-in response:" >&2
|
||||
cat "$signin_response" >&2 || true
|
||||
echo >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
auto_bootstrap_authenticated_smoke() {
|
||||
local health_url="$PAPERCLIP_PUBLIC_URL/api/health"
|
||||
local health_json
|
||||
health_json="$(curl -fsS "$health_url")"
|
||||
if [[ "$health_json" != *'"deploymentMode":"authenticated"'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sign_up_or_sign_in
|
||||
|
||||
if [[ "$health_json" == *'"bootstrapStatus":"ready"'* ]]; then
|
||||
echo " Smoke bootstrap: instance already ready"
|
||||
else
|
||||
local invite_url
|
||||
invite_url="$(generate_bootstrap_invite_url)"
|
||||
echo " Smoke bootstrap: generated bootstrap invite via auth bootstrap-ceo"
|
||||
|
||||
local invite_token="${invite_url##*/}"
|
||||
local accept_response="$TMP_DIR/accept.json"
|
||||
local accept_status
|
||||
accept_status="$(post_json_with_cookies \
|
||||
"$PAPERCLIP_PUBLIC_URL/api/invites/$invite_token/accept" \
|
||||
'{"requestType":"human"}' \
|
||||
"$accept_response")"
|
||||
if [[ ! "$accept_status" =~ ^2 ]]; then
|
||||
echo "Smoke bootstrap failed: bootstrap invite acceptance returned HTTP $accept_status" >&2
|
||||
cat "$accept_response" >&2 || true
|
||||
echo >&2
|
||||
return 1
|
||||
fi
|
||||
echo " Smoke bootstrap: accepted bootstrap invite"
|
||||
fi
|
||||
|
||||
local session_json
|
||||
session_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/auth/get-session")"
|
||||
if [[ "$session_json" != *'"userId"'* ]]; then
|
||||
echo "Smoke bootstrap failed: no authenticated session after bootstrap" >&2
|
||||
echo "$session_json" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local companies_json
|
||||
companies_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/companies")"
|
||||
if [[ "${companies_json:0:1}" != "[" ]]; then
|
||||
echo "Smoke bootstrap failed: board companies endpoint did not return JSON array" >&2
|
||||
echo "$companies_json" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Smoke bootstrap: board session verified"
|
||||
echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD"
|
||||
}
|
||||
|
||||
echo "==> Building onboard smoke image"
|
||||
docker build \
|
||||
--build-arg PAPERCLIPAI_VERSION="$PAPERCLIPAI_VERSION" \
|
||||
@@ -27,16 +212,38 @@ docker build \
|
||||
|
||||
echo "==> Running onboard smoke container"
|
||||
echo " UI should be reachable at: http://localhost:$HOST_PORT"
|
||||
echo " Public URL: $PAPERCLIP_PUBLIC_URL"
|
||||
echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP"
|
||||
echo " Data dir: $DATA_DIR"
|
||||
echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE"
|
||||
echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)"
|
||||
docker run --rm \
|
||||
"${DOCKER_TTY_ARGS[@]}" \
|
||||
--name "${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" \
|
||||
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-p "$HOST_PORT:3100" \
|
||||
-e HOST=0.0.0.0 \
|
||||
-e PORT=3100 \
|
||||
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
|
||||
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
|
||||
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
|
||||
-v "$DATA_DIR:/paperclip" \
|
||||
"$IMAGE_NAME"
|
||||
"$IMAGE_NAME" >/dev/null
|
||||
|
||||
docker logs -f "$CONTAINER_NAME" &
|
||||
LOG_PID=$!
|
||||
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")"
|
||||
COOKIE_JAR="$TMP_DIR/cookies.txt"
|
||||
|
||||
if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then
|
||||
echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "authenticated" ]]; then
|
||||
auto_bootstrap_authenticated_smoke
|
||||
fi
|
||||
|
||||
wait "$LOG_PID"
|
||||
|
||||
222
scripts/release-lib.sh
Normal file
222
scripts/release-lib.sh
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fi
|
||||
|
||||
release_info() {
|
||||
echo "$@"
|
||||
}
|
||||
|
||||
release_warn() {
|
||||
echo "Warning: $*" >&2
|
||||
}
|
||||
|
||||
release_fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
git_remote_exists() {
|
||||
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
resolve_release_remote() {
|
||||
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"
|
||||
|
||||
if [ -n "$remote" ]; then
|
||||
git_remote_exists "$remote" || release_fail "git remote '$remote' does not exist."
|
||||
printf '%s\n' "$remote"
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists public-gh; then
|
||||
printf 'public-gh\n'
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists origin; then
|
||||
printf 'origin\n'
|
||||
return
|
||||
fi
|
||||
|
||||
release_fail "no git remote found. Configure RELEASE_REMOTE or PUBLISH_REMOTE."
|
||||
}
|
||||
|
||||
fetch_release_remote() {
|
||||
git -C "$REPO_ROOT" fetch "$1" --prune --tags
|
||||
}
|
||||
|
||||
get_last_stable_tag() {
|
||||
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
|
||||
}
|
||||
|
||||
get_current_stable_version() {
|
||||
local tag
|
||||
tag="$(get_last_stable_tag)"
|
||||
if [ -z "$tag" ]; then
|
||||
printf '0.0.0\n'
|
||||
else
|
||||
printf '%s\n' "${tag#v}"
|
||||
fi
|
||||
}
|
||||
|
||||
compute_bumped_version() {
|
||||
node - "$1" "$2" <<'NODE'
|
||||
const current = process.argv[2];
|
||||
const bump = process.argv[3];
|
||||
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`invalid semver version: ${current}`);
|
||||
}
|
||||
|
||||
let [major, minor, patch] = match.slice(1).map(Number);
|
||||
|
||||
if (bump === 'patch') {
|
||||
patch += 1;
|
||||
} else if (bump === 'minor') {
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
} else if (bump === 'major') {
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
throw new Error(`unsupported bump type: ${bump}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${major}.${minor}.${patch}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
next_canary_version() {
|
||||
local stable_version="$1"
|
||||
local versions_json
|
||||
|
||||
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
|
||||
|
||||
node - "$stable_version" "$versions_json" <<'NODE'
|
||||
const stable = process.argv[2];
|
||||
const versionsArg = process.argv[3];
|
||||
|
||||
let versions = [];
|
||||
try {
|
||||
const parsed = JSON.parse(versionsArg);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
|
||||
process.stdout.write(`${stable}-canary.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
release_branch_name() {
|
||||
printf 'release/%s\n' "$1"
|
||||
}
|
||||
|
||||
release_notes_file() {
|
||||
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
|
||||
}
|
||||
|
||||
default_release_worktree_path() {
|
||||
local version="$1"
|
||||
local parent_dir
|
||||
local repo_name
|
||||
|
||||
parent_dir="$(cd "$REPO_ROOT/.." && pwd)"
|
||||
repo_name="$(basename "$REPO_ROOT")"
|
||||
printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version"
|
||||
}
|
||||
|
||||
git_current_branch() {
|
||||
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
|
||||
}
|
||||
|
||||
git_local_branch_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1"
|
||||
}
|
||||
|
||||
git_remote_branch_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
git_local_tag_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
|
||||
}
|
||||
|
||||
git_remote_tag_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
npm_version_exists() {
|
||||
local version="$1"
|
||||
local resolved
|
||||
|
||||
resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)"
|
||||
[ "$resolved" = "$version" ]
|
||||
}
|
||||
|
||||
require_clean_worktree() {
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
|
||||
fi
|
||||
}
|
||||
|
||||
git_worktree_path_for_branch() {
|
||||
local branch_ref="refs/heads/$1"
|
||||
|
||||
git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" '
|
||||
$1 == "worktree" { path = substr($0, 10) }
|
||||
$1 == "branch" && $2 == branch_ref { print path; exit }
|
||||
'
|
||||
}
|
||||
|
||||
path_is_worktree_for_branch() {
|
||||
local path="$1"
|
||||
local branch="$2"
|
||||
local current_branch
|
||||
|
||||
[ -d "$path" ] || return 1
|
||||
current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "$branch" ]
|
||||
}
|
||||
|
||||
ensure_release_branch_for_version() {
|
||||
local stable_version="$1"
|
||||
local current_branch
|
||||
local expected_branch
|
||||
|
||||
current_branch="$(git_current_branch)"
|
||||
expected_branch="$(release_branch_name "$stable_version")"
|
||||
|
||||
if [ -z "$current_branch" ]; then
|
||||
release_fail "release work must run from branch $expected_branch, but HEAD is detached."
|
||||
fi
|
||||
|
||||
if [ "$current_branch" != "$expected_branch" ]; then
|
||||
release_fail "release work must run from branch $expected_branch, but current branch is $current_branch."
|
||||
fi
|
||||
}
|
||||
|
||||
stable_release_exists_anywhere() {
|
||||
local stable_version="$1"
|
||||
local remote="$2"
|
||||
local tag="v$stable_version"
|
||||
|
||||
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
|
||||
}
|
||||
|
||||
release_train_is_frozen() {
|
||||
stable_release_exists_anywhere "$1" "$2"
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
export GIT_PAGER=cat
|
||||
|
||||
channel=""
|
||||
@@ -18,7 +20,9 @@ Examples:
|
||||
|
||||
What it does:
|
||||
- verifies the git worktree is clean, including untracked files
|
||||
- verifies you are on the matching release/X.Y.Z branch
|
||||
- shows the last stable tag and the target version(s)
|
||||
- shows the git/npm/GitHub release-train state
|
||||
- shows commits since the last stable tag
|
||||
- highlights migration/schema/breaking-change signals
|
||||
- runs the verification gate:
|
||||
@@ -63,79 +67,19 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compute_bumped_version() {
|
||||
node - "$1" "$2" <<'NODE'
|
||||
const current = process.argv[2];
|
||||
const bump = process.argv[3];
|
||||
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`invalid semver version: ${current}`);
|
||||
}
|
||||
|
||||
let [major, minor, patch] = match.slice(1).map(Number);
|
||||
|
||||
if (bump === 'patch') {
|
||||
patch += 1;
|
||||
} else if (bump === 'minor') {
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
} else if (bump === 'major') {
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
throw new Error(`unsupported bump type: ${bump}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${major}.${minor}.${patch}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
next_canary_version() {
|
||||
local stable_version="$1"
|
||||
local versions_json
|
||||
|
||||
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
|
||||
|
||||
node - "$stable_version" "$versions_json" <<'NODE'
|
||||
const stable = process.argv[2];
|
||||
const versionsArg = process.argv[3];
|
||||
|
||||
let versions = [];
|
||||
try {
|
||||
const parsed = JSON.parse(versionsArg);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
|
||||
process.stdout.write(`${stable}-canary.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
|
||||
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
|
||||
if [ -z "$CURRENT_STABLE_VERSION" ]; then
|
||||
CURRENT_STABLE_VERSION="0.0.0"
|
||||
fi
|
||||
RELEASE_REMOTE="$(resolve_release_remote)"
|
||||
fetch_release_remote "$RELEASE_REMOTE"
|
||||
|
||||
LAST_STABLE_TAG="$(get_last_stable_tag)"
|
||||
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
|
||||
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
|
||||
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
|
||||
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
|
||||
CURRENT_BRANCH="$(git_current_branch)"
|
||||
RELEASE_TAG="v$TARGET_STABLE_VERSION"
|
||||
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
|
||||
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2
|
||||
exit 1
|
||||
fi
|
||||
require_clean_worktree
|
||||
|
||||
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
|
||||
echo "Error: next stable version matches the current stable version." >&2
|
||||
@@ -147,10 +91,41 @@ if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
|
||||
|
||||
REMOTE_BRANCH_EXISTS="no"
|
||||
REMOTE_TAG_EXISTS="no"
|
||||
LOCAL_TAG_EXISTS="no"
|
||||
NPM_STABLE_EXISTS="no"
|
||||
|
||||
if git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$RELEASE_REMOTE"; then
|
||||
REMOTE_BRANCH_EXISTS="yes"
|
||||
fi
|
||||
|
||||
if git_local_tag_exists "$RELEASE_TAG"; then
|
||||
LOCAL_TAG_EXISTS="yes"
|
||||
fi
|
||||
|
||||
if git_remote_tag_exists "$RELEASE_TAG" "$RELEASE_REMOTE"; then
|
||||
REMOTE_TAG_EXISTS="yes"
|
||||
fi
|
||||
|
||||
if npm_version_exists "$TARGET_STABLE_VERSION"; then
|
||||
NPM_STABLE_EXISTS="yes"
|
||||
fi
|
||||
|
||||
if [ "$LOCAL_TAG_EXISTS" = "yes" ] || [ "$REMOTE_TAG_EXISTS" = "yes" ] || [ "$NPM_STABLE_EXISTS" = "yes" ]; then
|
||||
echo "Error: release train $EXPECTED_RELEASE_BRANCH is frozen because $RELEASE_TAG already exists locally, remotely, or version $TARGET_STABLE_VERSION is already on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Release preflight"
|
||||
echo " Remote: $RELEASE_REMOTE"
|
||||
echo " Channel: $channel"
|
||||
echo " Bump: $bump_type"
|
||||
echo " Current branch: ${CURRENT_BRANCH:-<detached>}"
|
||||
echo " Expected branch: $EXPECTED_RELEASE_BRANCH"
|
||||
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
echo " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
echo " Next stable version: $TARGET_STABLE_VERSION"
|
||||
@@ -162,6 +137,23 @@ fi
|
||||
echo ""
|
||||
echo "==> Working tree"
|
||||
echo " ✓ Clean"
|
||||
echo " ✓ Branch matches release train"
|
||||
|
||||
echo ""
|
||||
echo "==> Release train state"
|
||||
echo " Remote branch exists: $REMOTE_BRANCH_EXISTS"
|
||||
echo " Local stable tag exists: $LOCAL_TAG_EXISTS"
|
||||
echo " Remote stable tag exists: $REMOTE_TAG_EXISTS"
|
||||
echo " Stable version on npm: $NPM_STABLE_EXISTS"
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
echo " Release notes: present at $NOTES_FILE"
|
||||
else
|
||||
echo " Release notes: missing at $NOTES_FILE"
|
||||
fi
|
||||
|
||||
if [ "$REMOTE_BRANCH_EXISTS" = "no" ]; then
|
||||
echo " Warning: remote branch $EXPECTED_RELEASE_BRANCH does not exist on $RELEASE_REMOTE yet."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Commits since last stable tag"
|
||||
@@ -193,8 +185,10 @@ pnpm build
|
||||
|
||||
echo ""
|
||||
echo "==> Release preflight summary"
|
||||
echo " Remote: $RELEASE_REMOTE"
|
||||
echo " Channel: $channel"
|
||||
echo " Bump: $bump_type"
|
||||
echo " Release branch: $EXPECTED_RELEASE_BRANCH"
|
||||
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
echo " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
echo " Next stable version: $TARGET_STABLE_VERSION"
|
||||
|
||||
182
scripts/release-start.sh
Executable file
182
scripts/release-start.sh
Executable file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
|
||||
dry_run=false
|
||||
push_branch=true
|
||||
bump_type=""
|
||||
worktree_path=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release-start.sh <patch|minor|major> [--dry-run] [--no-push] [--worktree-dir PATH]
|
||||
|
||||
Examples:
|
||||
./scripts/release-start.sh patch
|
||||
./scripts/release-start.sh minor --dry-run
|
||||
./scripts/release-start.sh major --worktree-dir ../paperclip-release-1.0.0
|
||||
|
||||
What it does:
|
||||
- fetches the release remote and tags
|
||||
- computes the next stable version from the latest stable tag
|
||||
- creates or resumes branch release/X.Y.Z
|
||||
- creates or resumes a dedicated worktree for that branch
|
||||
- pushes the release branch to the remote by default
|
||||
|
||||
Notes:
|
||||
- Stable publishes freeze a release train. If vX.Y.Z already exists locally,
|
||||
remotely, or on npm, this script refuses to reuse release/X.Y.Z.
|
||||
- Use --no-push only if you intentionally do not want the release branch on
|
||||
GitHub yet.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=true ;;
|
||||
--no-push) push_branch=false ;;
|
||||
--worktree-dir)
|
||||
shift
|
||||
[ $# -gt 0 ] || release_fail "--worktree-dir requires a path."
|
||||
worktree_path="$1"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -n "$bump_type" ]; then
|
||||
release_fail "only one bump type may be provided."
|
||||
fi
|
||||
bump_type="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_remote="$(resolve_release_remote)"
|
||||
fetch_release_remote "$release_remote"
|
||||
|
||||
last_stable_tag="$(get_last_stable_tag)"
|
||||
current_stable_version="$(get_current_stable_version)"
|
||||
target_stable_version="$(compute_bumped_version "$current_stable_version" "$bump_type")"
|
||||
target_canary_version="$(next_canary_version "$target_stable_version")"
|
||||
release_branch="$(release_branch_name "$target_stable_version")"
|
||||
release_tag="v$target_stable_version"
|
||||
|
||||
if [ -z "$worktree_path" ]; then
|
||||
worktree_path="$(default_release_worktree_path "$target_stable_version")"
|
||||
fi
|
||||
|
||||
if stable_release_exists_anywhere "$target_stable_version" "$release_remote"; then
|
||||
release_fail "release train $release_branch is frozen because $release_tag already exists locally, remotely, or version $target_stable_version is already on npm."
|
||||
fi
|
||||
|
||||
branch_exists_local=false
|
||||
branch_exists_remote=false
|
||||
branch_worktree_path=""
|
||||
created_worktree=false
|
||||
created_branch=false
|
||||
pushed_branch=false
|
||||
|
||||
if git_local_branch_exists "$release_branch"; then
|
||||
branch_exists_local=true
|
||||
fi
|
||||
|
||||
if git_remote_branch_exists "$release_branch" "$release_remote"; then
|
||||
branch_exists_remote=true
|
||||
fi
|
||||
|
||||
branch_worktree_path="$(git_worktree_path_for_branch "$release_branch")"
|
||||
if [ -n "$branch_worktree_path" ]; then
|
||||
worktree_path="$branch_worktree_path"
|
||||
fi
|
||||
|
||||
if [ -e "$worktree_path" ] && ! path_is_worktree_for_branch "$worktree_path" "$release_branch"; then
|
||||
release_fail "path $worktree_path already exists and is not a worktree for $release_branch."
|
||||
fi
|
||||
|
||||
if [ -z "$branch_worktree_path" ]; then
|
||||
if [ "$dry_run" = true ]; then
|
||||
if [ "$branch_exists_local" = true ] || [ "$branch_exists_remote" = true ]; then
|
||||
release_info "[dry-run] Would add worktree $worktree_path for existing branch $release_branch"
|
||||
else
|
||||
release_info "[dry-run] Would create branch $release_branch from $release_remote/master"
|
||||
release_info "[dry-run] Would add worktree $worktree_path"
|
||||
fi
|
||||
else
|
||||
if [ "$branch_exists_local" = true ]; then
|
||||
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
|
||||
elif [ "$branch_exists_remote" = true ]; then
|
||||
git -C "$REPO_ROOT" branch --track "$release_branch" "$release_remote/$release_branch"
|
||||
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
|
||||
created_branch=true
|
||||
else
|
||||
git -C "$REPO_ROOT" worktree add -b "$release_branch" "$worktree_path" "$release_remote/master"
|
||||
created_branch=true
|
||||
fi
|
||||
created_worktree=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = false ] && [ "$push_branch" = true ] && [ "$branch_exists_remote" = false ]; then
|
||||
git -C "$worktree_path" push -u "$release_remote" "$release_branch"
|
||||
pushed_branch=true
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = false ] && [ "$branch_exists_remote" = true ]; then
|
||||
git -C "$worktree_path" branch --set-upstream-to "$release_remote/$release_branch" "$release_branch" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Release train"
|
||||
release_info " Remote: $release_remote"
|
||||
release_info " Last stable tag: ${last_stable_tag:-<none>}"
|
||||
release_info " Current stable version: $current_stable_version"
|
||||
release_info " Bump: $bump_type"
|
||||
release_info " Target stable version: $target_stable_version"
|
||||
release_info " Next canary version: $target_canary_version"
|
||||
release_info " Branch: $release_branch"
|
||||
release_info " Tag (reserved until stable publish): $release_tag"
|
||||
release_info " Worktree: $worktree_path"
|
||||
release_info " Release notes path: $worktree_path/releases/v${target_stable_version}.md"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Status"
|
||||
if [ -n "$branch_worktree_path" ]; then
|
||||
release_info " ✓ Reusing existing worktree for $release_branch"
|
||||
elif [ "$dry_run" = true ]; then
|
||||
release_info " ✓ Dry run only; no branch or worktree created"
|
||||
else
|
||||
[ "$created_branch" = true ] && release_info " ✓ Created branch $release_branch"
|
||||
[ "$created_worktree" = true ] && release_info " ✓ Created worktree $worktree_path"
|
||||
fi
|
||||
|
||||
if [ "$branch_exists_remote" = true ]; then
|
||||
release_info " ✓ Remote branch already exists on $release_remote"
|
||||
elif [ "$dry_run" = true ] && [ "$push_branch" = true ]; then
|
||||
release_info " [dry-run] Would push $release_branch to $release_remote"
|
||||
elif [ "$push_branch" = true ] && [ "$pushed_branch" = true ]; then
|
||||
release_info " ✓ Pushed $release_branch to $release_remote"
|
||||
elif [ "$push_branch" = false ]; then
|
||||
release_warn "release branch was not pushed. Stable publish will later refuse until the branch exists on $release_remote."
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "Next steps:"
|
||||
release_info " cd $worktree_path"
|
||||
release_info " Draft or update releases/v${target_stable_version}.md"
|
||||
release_info " ./scripts/release-preflight.sh canary $bump_type"
|
||||
release_info " ./scripts/release.sh $bump_type --canary"
|
||||
release_info ""
|
||||
release_info "Merge rule:"
|
||||
release_info " Merge $release_branch back to master without squash or rebase so tag $release_tag remains reachable from master."
|
||||
@@ -15,10 +15,11 @@ set -euo pipefail
|
||||
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
CLI_DIR="$REPO_ROOT/cli"
|
||||
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
|
||||
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
|
||||
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
|
||||
|
||||
dry_run=false
|
||||
canary=false
|
||||
@@ -41,6 +42,7 @@ Notes:
|
||||
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm
|
||||
dist-tag "canary".
|
||||
- Stable publishes 1.2.3 under the npm dist-tag "latest".
|
||||
- Run this from branch release/X.Y.Z matching the computed target version.
|
||||
- Dry runs leave the working tree clean.
|
||||
EOF
|
||||
}
|
||||
@@ -73,15 +75,6 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info() {
|
||||
echo "$@"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
restore_publish_artifacts() {
|
||||
if [ -f "$CLI_DIR/package.dev.json" ]; then
|
||||
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
|
||||
@@ -130,28 +123,22 @@ set_cleanup_trap() {
|
||||
trap cleanup_release_state EXIT
|
||||
}
|
||||
|
||||
require_clean_worktree() {
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
fail "working tree is not clean. Commit, stash, or remove changes before releasing."
|
||||
fi
|
||||
}
|
||||
|
||||
require_npm_publish_auth() {
|
||||
if [ "$dry_run" = true ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if npm whoami >/dev/null 2>&1; then
|
||||
info " ✓ Logged in to npm as $(npm whoami)"
|
||||
release_info " ✓ Logged in to npm as $(npm whoami)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
|
||||
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
|
||||
return
|
||||
fi
|
||||
|
||||
fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
|
||||
release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
|
||||
}
|
||||
|
||||
list_public_package_info() {
|
||||
@@ -202,66 +189,6 @@ for (const [dir, name] of rows) {
|
||||
NODE
|
||||
}
|
||||
|
||||
compute_bumped_version() {
|
||||
node - "$1" "$2" <<'NODE'
|
||||
const current = process.argv[2];
|
||||
const bump = process.argv[3];
|
||||
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`invalid semver version: ${current}`);
|
||||
}
|
||||
|
||||
let [major, minor, patch] = match.slice(1).map(Number);
|
||||
|
||||
if (bump === 'patch') {
|
||||
patch += 1;
|
||||
} else if (bump === 'minor') {
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
} else if (bump === 'major') {
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
throw new Error(`unsupported bump type: ${bump}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${major}.${minor}.${patch}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
next_canary_version() {
|
||||
local stable_version="$1"
|
||||
local versions_json
|
||||
|
||||
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
|
||||
|
||||
node - "$stable_version" "$versions_json" <<'NODE'
|
||||
const stable = process.argv[2];
|
||||
const versionsArg = process.argv[3];
|
||||
|
||||
let versions = [];
|
||||
try {
|
||||
const parsed = JSON.parse(versionsArg);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
|
||||
process.stdout.write(`${stable}-canary.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
replace_version_string() {
|
||||
local from_version="$1"
|
||||
local to_version="$2"
|
||||
@@ -312,25 +239,55 @@ for (const relFile of extraFiles) {
|
||||
NODE
|
||||
}
|
||||
|
||||
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
|
||||
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
|
||||
if [ -z "$CURRENT_STABLE_VERSION" ]; then
|
||||
CURRENT_STABLE_VERSION="0.0.0"
|
||||
fi
|
||||
PUBLISH_REMOTE="$(resolve_release_remote)"
|
||||
fetch_release_remote "$PUBLISH_REMOTE"
|
||||
|
||||
LAST_STABLE_TAG="$(get_last_stable_tag)"
|
||||
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
|
||||
|
||||
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
|
||||
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
|
||||
CURRENT_BRANCH="$(git_current_branch)"
|
||||
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
|
||||
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
|
||||
RELEASE_TAG="v$TARGET_STABLE_VERSION"
|
||||
|
||||
if [ "$canary" = true ]; then
|
||||
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
|
||||
fi
|
||||
|
||||
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
|
||||
fail "next stable version matches the current stable version. Refusing to publish."
|
||||
release_fail "next stable version matches the current stable version. Refusing to publish."
|
||||
fi
|
||||
|
||||
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
|
||||
fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
|
||||
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
|
||||
fi
|
||||
|
||||
require_clean_worktree
|
||||
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
|
||||
|
||||
if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then
|
||||
release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE."
|
||||
fi
|
||||
|
||||
if npm_version_exists "$TARGET_STABLE_VERSION"; then
|
||||
release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH."
|
||||
fi
|
||||
|
||||
if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
|
||||
fi
|
||||
|
||||
if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable."
|
||||
fi
|
||||
|
||||
if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then
|
||||
if [ "$canary" = false ] && [ "$dry_run" = false ]; then
|
||||
release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish."
|
||||
fi
|
||||
release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet."
|
||||
fi
|
||||
|
||||
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
|
||||
@@ -338,33 +295,36 @@ PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
|
||||
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
|
||||
|
||||
if [ -z "$PUBLIC_PACKAGE_INFO" ]; then
|
||||
fail "no public packages were found in the workspace."
|
||||
release_fail "no public packages were found in the workspace."
|
||||
fi
|
||||
|
||||
info ""
|
||||
info "==> Release plan"
|
||||
info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
info " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
release_info ""
|
||||
release_info "==> Release plan"
|
||||
release_info " Remote: $PUBLISH_REMOTE"
|
||||
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
|
||||
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
|
||||
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
release_info " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
if [ "$canary" = true ]; then
|
||||
info " Target stable version: $TARGET_STABLE_VERSION"
|
||||
info " Canary version: $TARGET_PUBLISH_VERSION"
|
||||
info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
|
||||
release_info " Target stable version: $TARGET_STABLE_VERSION"
|
||||
release_info " Canary version: $TARGET_PUBLISH_VERSION"
|
||||
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
|
||||
else
|
||||
info " Stable version: $TARGET_STABLE_VERSION"
|
||||
release_info " Stable version: $TARGET_STABLE_VERSION"
|
||||
fi
|
||||
|
||||
info ""
|
||||
info "==> Step 1/7: Preflight checks..."
|
||||
require_clean_worktree
|
||||
info " ✓ Working tree is clean"
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Preflight checks..."
|
||||
release_info " ✓ Working tree is clean"
|
||||
release_info " ✓ Branch matches release train"
|
||||
require_npm_publish_auth
|
||||
|
||||
if [ "$dry_run" = true ] || [ "$canary" = true ]; then
|
||||
set_cleanup_trap
|
||||
fi
|
||||
|
||||
info ""
|
||||
info "==> Step 2/7: Creating release changeset..."
|
||||
release_info ""
|
||||
release_info "==> Step 2/7: Creating release changeset..."
|
||||
{
|
||||
echo "---"
|
||||
while IFS= read -r pkg_name; do
|
||||
@@ -379,10 +339,10 @@ info "==> Step 2/7: Creating release changeset..."
|
||||
echo "Stable release preparation for $TARGET_STABLE_VERSION"
|
||||
fi
|
||||
} > "$TEMP_CHANGESET_FILE"
|
||||
info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
|
||||
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
|
||||
|
||||
info ""
|
||||
info "==> Step 3/7: Versioning packages..."
|
||||
release_info ""
|
||||
release_info "==> Step 3/7: Versioning packages..."
|
||||
cd "$REPO_ROOT"
|
||||
if [ "$canary" = true ]; then
|
||||
npx changeset pre enter canary
|
||||
@@ -398,12 +358,12 @@ fi
|
||||
|
||||
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
|
||||
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
|
||||
fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
fi
|
||||
info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
|
||||
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
|
||||
|
||||
info ""
|
||||
info "==> Step 4/7: Building workspace artifacts..."
|
||||
release_info ""
|
||||
release_info "==> Step 4/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
@@ -411,49 +371,49 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc
|
||||
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
||||
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
|
||||
done
|
||||
info " ✓ Workspace build complete"
|
||||
release_info " ✓ Workspace build complete"
|
||||
|
||||
info ""
|
||||
info "==> Step 5/7: Building publishable CLI bundle..."
|
||||
release_info ""
|
||||
release_info "==> Step 5/7: Building publishable CLI bundle..."
|
||||
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
|
||||
info " ✓ CLI bundle ready"
|
||||
release_info " ✓ CLI bundle ready"
|
||||
|
||||
info ""
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
|
||||
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
|
||||
while IFS= read -r pkg_dir; do
|
||||
[ -z "$pkg_dir" ] && continue
|
||||
info " --- $pkg_dir ---"
|
||||
release_info " --- $pkg_dir ---"
|
||||
cd "$REPO_ROOT/$pkg_dir"
|
||||
npm pack --dry-run 2>&1 | tail -3
|
||||
done <<< "$PUBLIC_PACKAGE_DIRS"
|
||||
cd "$REPO_ROOT"
|
||||
if [ "$canary" = true ]; then
|
||||
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
else
|
||||
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
fi
|
||||
else
|
||||
if [ "$canary" = true ]; then
|
||||
info "==> Step 6/7: Publishing canary to npm..."
|
||||
release_info "==> Step 6/7: Publishing canary to npm..."
|
||||
npx changeset publish
|
||||
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
else
|
||||
info "==> Step 6/7: Publishing stable release to npm..."
|
||||
release_info "==> Step 6/7: Publishing stable release to npm..."
|
||||
npx changeset publish
|
||||
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
info ""
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
info "==> Step 7/7: Cleaning up dry-run state..."
|
||||
info " ✓ Dry run leaves the working tree unchanged"
|
||||
release_info "==> Step 7/7: Cleaning up dry-run state..."
|
||||
release_info " ✓ Dry run leaves the working tree unchanged"
|
||||
elif [ "$canary" = true ]; then
|
||||
info "==> Step 7/7: Cleaning up canary state..."
|
||||
info " ✓ Canary state will be discarded after publish"
|
||||
release_info "==> Step 7/7: Cleaning up canary state..."
|
||||
release_info " ✓ Canary state will be discarded after publish"
|
||||
else
|
||||
info "==> Step 7/7: Finalizing stable release commit..."
|
||||
release_info "==> Step 7/7: Finalizing stable release commit..."
|
||||
restore_publish_artifacts
|
||||
|
||||
git -C "$REPO_ROOT" add -u .changeset packages server cli
|
||||
@@ -463,23 +423,24 @@ else
|
||||
|
||||
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
|
||||
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
|
||||
info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
|
||||
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
|
||||
fi
|
||||
|
||||
info ""
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
if [ "$canary" = true ]; then
|
||||
info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
|
||||
else
|
||||
info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
|
||||
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
|
||||
fi
|
||||
elif [ "$canary" = true ]; then
|
||||
info "Published canary ${TARGET_PUBLISH_VERSION}."
|
||||
info "Install with: npx paperclipai@canary onboard"
|
||||
info "Stable version remains: $CURRENT_STABLE_VERSION"
|
||||
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Install with: npx paperclipai@canary onboard"
|
||||
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
|
||||
else
|
||||
info "Published stable v${TARGET_STABLE_VERSION}."
|
||||
info "Next steps:"
|
||||
info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags"
|
||||
info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
|
||||
release_info "Published stable v${TARGET_STABLE_VERSION}."
|
||||
release_info "Next steps:"
|
||||
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
|
||||
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
|
||||
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase"
|
||||
fi
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
|
||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run prepare:ui-dist",
|
||||
@@ -64,6 +64,7 @@
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -4,6 +4,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
const itWindows = process.platform === "win32" ? it : it.skip;
|
||||
|
||||
describe("codex_local environment diagnostics", () => {
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
@@ -29,4 +31,45 @@ describe("codex_local environment diagnostics", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-codex-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const fakeCodex = path.join(binDir, "codex.cmd");
|
||||
const script = [
|
||||
"@echo off",
|
||||
"echo {\"type\":\"thread.started\",\"thread_id\":\"test-thread\"}",
|
||||
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}",
|
||||
"echo {\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}",
|
||||
"exit /b 0",
|
||||
"",
|
||||
].join("\r\n");
|
||||
|
||||
try {
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(fakeCodex, script, "utf8");
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd,
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-key",
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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,10 +2,7 @@ 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 {
|
||||
buildOpenClawGatewayConfig,
|
||||
parseOpenClawGatewayStdoutLine,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
@@ -39,9 +36,7 @@ function buildContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer(options?: {
|
||||
waitPayload?: Record<string, unknown>;
|
||||
}) {
|
||||
async function createMockGatewayServer() {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
@@ -141,7 +136,7 @@ async function createMockGatewayServer(options?: {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: options?.waitPayload ?? {
|
||||
payload: {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
@@ -417,29 +412,6 @@ 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -456,33 +428,6 @@ 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 {
|
||||
@@ -496,54 +441,6 @@ 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[] = [];
|
||||
@@ -582,62 +479,6 @@ 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({
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
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,7 +32,6 @@ export async function createApp(
|
||||
db: Db,
|
||||
opts: {
|
||||
uiMode: UiMode;
|
||||
serverPort: number;
|
||||
storageService: StorageService;
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
@@ -147,18 +146,12 @@ 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, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
|
||||
import { heartbeatService } from "./services/index.js";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
@@ -460,12 +460,10 @@ 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,
|
||||
@@ -477,6 +475,7 @@ 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})`);
|
||||
@@ -495,19 +494,6 @@ 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);
|
||||
@@ -516,7 +502,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())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { count, sql } from "drizzle-orm";
|
||||
import { instanceUserRoles } from "@paperclipai/db";
|
||||
import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
|
||||
import { instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
|
||||
export function healthRoutes(
|
||||
@@ -27,6 +27,7 @@ export function healthRoutes(
|
||||
}
|
||||
|
||||
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
|
||||
let bootstrapInviteActive = false;
|
||||
if (opts.deploymentMode === "authenticated") {
|
||||
const roleCount = await db
|
||||
.select({ count: count() })
|
||||
@@ -34,6 +35,23 @@ export function healthRoutes(
|
||||
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
|
||||
|
||||
if (bootstrapStatus === "bootstrap_pending") {
|
||||
const now = new Date();
|
||||
const inviteCount = await db
|
||||
.select({ count: count() })
|
||||
.from(invites)
|
||||
.where(
|
||||
and(
|
||||
eq(invites.inviteType, "bootstrap_ceo"),
|
||||
isNull(invites.revokedAt),
|
||||
isNull(invites.acceptedAt),
|
||||
gt(invites.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
bootstrapInviteActive = inviteCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -42,6 +60,7 @@ export function healthRoutes(
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
authReady: opts.authReady,
|
||||
bootstrapStatus,
|
||||
bootstrapInviteActive,
|
||||
features: {
|
||||
companyDeletionEnabled: opts.companyDeletionEnabled,
|
||||
},
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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,7 +11,6 @@ import {
|
||||
heartbeatRuns,
|
||||
costEvents,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
@@ -24,20 +23,6 @@ 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;
|
||||
@@ -421,7 +406,6 @@ 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
|
||||
@@ -1087,10 +1071,8 @@ 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)))
|
||||
@@ -1102,18 +1084,6 @@ 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;
|
||||
@@ -1123,72 +1093,20 @@ 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: executionWorkspaceMode !== "agent_default" },
|
||||
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
|
||||
);
|
||||
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,
|
||||
cwd: executionWorkspace.cwd,
|
||||
},
|
||||
resolvedWorkspace,
|
||||
});
|
||||
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
|
||||
const runtimeWorkspaceWarnings = [
|
||||
...resolvedWorkspace.warnings,
|
||||
...executionWorkspace.warnings,
|
||||
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
|
||||
...(resetTaskSession && sessionResetReason
|
||||
? [
|
||||
@@ -1199,33 +1117,16 @@ export function heartbeatService(db: Db) {
|
||||
: []),
|
||||
];
|
||||
context.paperclipWorkspace = {
|
||||
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,
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
source: resolvedWorkspace.source,
|
||||
projectId: resolvedWorkspace.projectId,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
repoUrl: resolvedWorkspace.repoUrl,
|
||||
repoRef: resolvedWorkspace.repoRef,
|
||||
};
|
||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
||||
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;
|
||||
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||
context.projectId = resolvedWorkspace.projectId;
|
||||
}
|
||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
||||
const previousSessionDisplayId = truncateDisplayId(
|
||||
@@ -1245,6 +1146,7 @@ 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
|
||||
@@ -1252,7 +1154,6 @@ export function heartbeatService(db: Db) {
|
||||
.set({
|
||||
startedAt,
|
||||
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
|
||||
contextSnapshot: context,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id))
|
||||
@@ -1334,54 +1235,15 @@ export function heartbeatService(db: Db) {
|
||||
for (const warning of runtimeWorkspaceWarnings) {
|
||||
await onLog("stderr", `[paperclip] ${warning}\n`);
|
||||
}
|
||||
const adapterEnv = Object.fromEntries(
|
||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
),
|
||||
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
||||
: config;
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const runtimeServices = await ensureRuntimeServicesForRun({
|
||||
db,
|
||||
runId: run.id,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
companyId: agent.companyId,
|
||||
},
|
||||
issue: issueRef,
|
||||
workspace: executionWorkspace,
|
||||
config: resolvedConfig,
|
||||
adapterEnv,
|
||||
onLog,
|
||||
});
|
||||
if (runtimeServices.length > 0) {
|
||||
context.paperclipRuntimeServices = runtimeServices;
|
||||
context.paperclipRuntimePrimaryUrl =
|
||||
runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
contextSnapshot: context,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
|
||||
try {
|
||||
await issuesSvc.addComment(
|
||||
issueId,
|
||||
buildWorkspaceReadyComment({
|
||||
workspace: executionWorkspace,
|
||||
runtimeServices,
|
||||
}),
|
||||
{ agentId: agent.id },
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
@@ -1422,54 +1284,6 @@ 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,
|
||||
@@ -1646,7 +1460,6 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
await finalizeAgentStatus(agent.id, "failed");
|
||||
} finally {
|
||||
await releaseRuntimeServicesForRun(run.id);
|
||||
await startNextQueuedRunForAgent(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,4 @@ 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,10 +18,6 @@ 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"];
|
||||
|
||||
@@ -639,19 +635,6 @@ 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` })
|
||||
@@ -661,13 +644,7 @@ export function issueService(db: Db) {
|
||||
const issueNumber = company.issueCounter;
|
||||
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
companyId,
|
||||
issueNumber,
|
||||
identifier,
|
||||
} as typeof issues.$inferInsert;
|
||||
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
|
||||
if (values.status === "in_progress" && !values.startedAt) {
|
||||
values.startedAt = new Date();
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces } 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;
|
||||
@@ -28,11 +23,10 @@ type CreateWorkspaceInput = {
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
interface ProjectWithGoals extends ProjectRow {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
}
|
||||
@@ -80,46 +74,11 @@ 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 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 {
|
||||
function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
@@ -130,20 +89,15 @@ function toWorkspace(
|
||||
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[],
|
||||
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
|
||||
): ProjectWorkspace | null {
|
||||
function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null {
|
||||
if (rows.length === 0) return null;
|
||||
const explicitPrimary = rows.find((row) => row.isPrimary);
|
||||
const primary = explicitPrimary ?? rows[0];
|
||||
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
|
||||
return toWorkspace(explicitPrimary ?? rows[0]);
|
||||
}
|
||||
|
||||
/** Batch-load workspace refs for a set of projects. */
|
||||
@@ -156,17 +110,6 @@ 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) {
|
||||
@@ -180,16 +123,11 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
|
||||
return rows.map((row) => {
|
||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||
toWorkspace(
|
||||
workspace,
|
||||
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
|
||||
),
|
||||
);
|
||||
const workspaces = projectWorkspaceRows.map(toWorkspace);
|
||||
return {
|
||||
...row,
|
||||
workspaces,
|
||||
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
|
||||
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -464,18 +402,7 @@ export function projectService(db: Db) {
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
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),
|
||||
),
|
||||
);
|
||||
return rows.map(toWorkspace);
|
||||
},
|
||||
|
||||
createWorkspace: async (
|
||||
|
||||
@@ -1,962 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -106,6 +106,25 @@ Guidelines:
|
||||
- keep highlights short and concrete
|
||||
- spell out upgrade actions for breaking changes
|
||||
|
||||
### Inline PR and contributor attribution
|
||||
|
||||
When a bullet item clearly maps to a merged pull request, add inline attribution at the
|
||||
end of the entry in this format:
|
||||
|
||||
```
|
||||
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
|
||||
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
|
||||
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
|
||||
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
|
||||
- If you cannot determine the PR number or contributor with confidence, omit the attribution
|
||||
parenthetical — do not guess.
|
||||
- Core maintainer commits that don't have an external PR can omit the parenthetical.
|
||||
|
||||
## Step 5 — Write the File
|
||||
|
||||
Template:
|
||||
@@ -124,10 +143,29 @@ Template:
|
||||
## Fixes
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@username1, @username2, @username3
|
||||
```
|
||||
|
||||
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
|
||||
|
||||
The `Contributors` section should always be included. List every person who authored
|
||||
commits in the release range, @-mentioning them by their **GitHub username** (not their
|
||||
real name or email). To find GitHub usernames:
|
||||
|
||||
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
|
||||
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
|
||||
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
|
||||
|
||||
**Never expose contributor email addresses.** Use `@username` only.
|
||||
|
||||
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
|
||||
in alphabetical order by GitHub username (case-insensitive).
|
||||
|
||||
## Step 6 — Review Before Release
|
||||
|
||||
Before handing it off:
|
||||
|
||||
@@ -13,10 +13,11 @@ Run the full Paperclip release as a maintainer workflow, not just an npm publish
|
||||
This skill coordinates:
|
||||
|
||||
- stable changelog drafting via `release-changelog`
|
||||
- release-train setup via `scripts/release-start.sh`
|
||||
- prerelease canary publishing via `scripts/release.sh --canary`
|
||||
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
|
||||
- stable publishing via `scripts/release.sh`
|
||||
- pushing the release commit and tag
|
||||
- pushing the stable branch commit and tag
|
||||
- GitHub Release creation via `scripts/create-github-release.sh`
|
||||
- website / announcement follow-up tasks
|
||||
|
||||
@@ -36,7 +37,7 @@ Before proceeding, verify all of the following:
|
||||
2. The repo working tree is clean, including untracked files.
|
||||
3. There are commits since the last stable tag.
|
||||
4. The release SHA has passed the verification gate or is about to.
|
||||
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
|
||||
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut.
|
||||
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
|
||||
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
|
||||
|
||||
@@ -55,13 +56,15 @@ Collect these inputs up front:
|
||||
|
||||
Paperclip now uses this release model:
|
||||
|
||||
1. Draft the **stable** changelog as `releases/vX.Y.Z.md`
|
||||
2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
|
||||
3. Smoke test the canary via Docker
|
||||
4. Publish the stable version `X.Y.Z`
|
||||
5. Push the release commit and tag
|
||||
6. Create the GitHub Release
|
||||
7. Complete website and announcement surfaces
|
||||
1. Start or resume `release/X.Y.Z`
|
||||
2. Draft the **stable** changelog as `releases/vX.Y.Z.md`
|
||||
3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
|
||||
4. Smoke test the canary via Docker
|
||||
5. Publish the stable version `X.Y.Z`
|
||||
6. Push the stable branch commit and tag
|
||||
7. Create the GitHub Release
|
||||
8. Merge `release/X.Y.Z` back to `master` without squash or rebase
|
||||
9. Complete website and announcement surfaces
|
||||
|
||||
Critical consequence:
|
||||
|
||||
@@ -70,7 +73,13 @@ Critical consequence:
|
||||
|
||||
## Step 1 — Decide the Stable Version
|
||||
|
||||
Run release preflight first:
|
||||
Start the release train first:
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh {patch|minor|major}
|
||||
```
|
||||
|
||||
Then run release preflight:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary {patch|minor|major}
|
||||
@@ -125,7 +134,7 @@ The GitHub Actions release workflow installs with `pnpm install --frozen-lockfil
|
||||
|
||||
## Step 4 — Publish a Canary
|
||||
|
||||
Run:
|
||||
Run from the `release/X.Y.Z` branch:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh {patch|minor|major} --canary --dry-run
|
||||
@@ -203,12 +212,14 @@ Stable publish does **not** push the release for you.
|
||||
After stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD:master --follow-tags
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
Use the stable changelog file as the GitHub Release notes source.
|
||||
|
||||
Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase.
|
||||
|
||||
## Step 8 — Finish the Other Surfaces
|
||||
|
||||
Create or verify follow-up work for:
|
||||
|
||||
18
tsconfig.base.json
Normal file
18
tsconfig.base.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./packages/adapter-utils" },
|
||||
{ "path": "./packages/shared" },
|
||||
{ "path": "./packages/db" },
|
||||
{ "path": "./packages/adapters/claude-local" },
|
||||
{ "path": "./packages/adapters/codex-local" },
|
||||
{ "path": "./packages/adapters/cursor-local" },
|
||||
{ "path": "./packages/adapters/openclaw-gateway" },
|
||||
{ "path": "./packages/adapters/opencode-local" },
|
||||
{ "path": "./packages/adapters/pi-local" },
|
||||
{ "path": "./server" },
|
||||
{ "path": "./ui" },
|
||||
{ "path": "./cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,14 +32,15 @@ import { queryKeys } from "./lib/queryKeys";
|
||||
import { useCompany } from "./context/CompanyContext";
|
||||
import { useDialog } from "./context/DialogContext";
|
||||
|
||||
function BootstrapPendingPage() {
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No instance admin exists yet. Run this command in your Paperclip environment to generate
|
||||
the first admin invite URL:
|
||||
{hasActiveInvite
|
||||
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
|
||||
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
@@ -55,6 +56,15 @@ function CloudAccessGate() {
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as
|
||||
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
|
||||
| undefined;
|
||||
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
|
||||
? 2000
|
||||
: false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
|
||||
@@ -78,7 +88,7 @@ function CloudAccessGate() {
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage />;
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
@@ -111,7 +121,6 @@ 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 />} />
|
||||
@@ -226,7 +235,6 @@ 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,7 +7,6 @@ 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";
|
||||
@@ -16,54 +15,38 @@ 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>
|
||||
<LocalWorkspaceRuntimeFields
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
eff={eff}
|
||||
mode={mode}
|
||||
adapterType={adapterType}
|
||||
models={models}
|
||||
/>
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -14,15 +13,12 @@ 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;
|
||||
@@ -85,17 +81,6 @@ 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AdapterConfigFieldsProps } from "./types";
|
||||
|
||||
export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -6,10 +6,6 @@ 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";
|
||||
@@ -116,22 +112,6 @@ 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">
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export type HealthStatus = {
|
||||
deploymentExposure?: "private" | "public";
|
||||
authReady?: boolean;
|
||||
bootstrapStatus?: "ready" | "bootstrap_pending";
|
||||
bootstrapInviteActive?: boolean;
|
||||
features?: {
|
||||
companyDeletionEnabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -176,16 +176,6 @@ 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;
|
||||
@@ -412,10 +402,7 @@ 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, executionWorkspaceSettings: null });
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
@@ -432,15 +419,7 @@ 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,
|
||||
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
|
||||
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
|
||||
: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
@@ -525,42 +504,6 @@ 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;
|
||||
useIsolatedExecutionWorkspace: boolean;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||
@@ -99,6 +99,7 @@ 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)) {
|
||||
@@ -126,6 +127,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -176,11 +180,10 @@ export function NewIssueDialog() {
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
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;
|
||||
@@ -297,7 +300,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
useIsolatedExecutionWorkspace,
|
||||
assigneeUseProjectWorkspace,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
@@ -309,7 +312,7 @@ export function NewIssueDialog() {
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
useIsolatedExecutionWorkspace,
|
||||
assigneeUseProjectWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
]);
|
||||
@@ -318,7 +321,6 @@ export function NewIssueDialog() {
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
setDialogCompanyId(selectedCompanyId);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (newIssueDefaults.title) {
|
||||
@@ -331,7 +333,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
} else if (draft && draft.title.trim()) {
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
@@ -342,7 +344,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
@@ -351,7 +353,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
@@ -361,6 +363,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,11 +396,10 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setExpanded(false);
|
||||
setDialogCompanyId(null);
|
||||
setCompanyOpen(false);
|
||||
executionWorkspaceDefaultProjectId.current = null;
|
||||
}
|
||||
|
||||
function handleCompanyChange(companyId: string) {
|
||||
@@ -408,7 +410,7 @@ export function NewIssueDialog() {
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeChrome(false);
|
||||
setUseIsolatedExecutionWorkspace(false);
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
@@ -424,14 +426,8 @@ 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(),
|
||||
@@ -441,7 +437,6 @@ export function NewIssueDialog() {
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -472,8 +467,6 @@ 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"
|
||||
@@ -510,26 +503,6 @@ 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 ?? [])]
|
||||
@@ -732,7 +705,7 @@ export function NewIssueDialog() {
|
||||
noneLabel="No project"
|
||||
searchPlaceholder="Search projects..."
|
||||
emptyMessage="No projects found."
|
||||
onChange={handleProjectChange}
|
||||
onChange={setProjectId}
|
||||
onConfirm={() => {
|
||||
descriptionEditorRef.current?.focus();
|
||||
}}
|
||||
@@ -767,34 +740,6 @@ 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
|
||||
@@ -855,6 +800,23 @@ 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>
|
||||
|
||||
@@ -500,10 +500,6 @@ export function OnboardingWizard() {
|
||||
setLoading(false);
|
||||
reset();
|
||||
closeOnboarding();
|
||||
if (createdCompanyPrefix && createdIssueRef) {
|
||||
navigate(`/${createdCompanyPrefix}/issues/${createdIssueRef}`);
|
||||
return;
|
||||
}
|
||||
if (createdCompanyPrefix) {
|
||||
navigate(`/${createdCompanyPrefix}/dashboard`);
|
||||
return;
|
||||
|
||||
@@ -11,10 +11,9 @@ interface PageTabBarProps {
|
||||
items: PageTabItem[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
align?: "center" | "start";
|
||||
}
|
||||
|
||||
export function PageTabBar({ items, value, onValueChange, align = "center" }: PageTabBarProps) {
|
||||
export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
if (isMobile && value !== undefined && onValueChange) {
|
||||
@@ -34,7 +33,7 @@ export function PageTabBar({ items, value, onValueChange, align = "center" }: Pa
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsList variant="line" className={align === "start" ? "justify-start" : undefined}>
|
||||
<TabsList variant="line">
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
|
||||
@@ -13,10 +13,8 @@ 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 { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { ExternalLink, Github, 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" },
|
||||
@@ -29,84 +27,15 @@ 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 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;
|
||||
}) {
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -147,25 +76,15 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
||||
export function ProjectProperties({ project, onUpdate }: 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!),
|
||||
@@ -187,16 +106,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
|
||||
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) });
|
||||
@@ -227,29 +136,16 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
});
|
||||
|
||||
const removeGoal = (goalId: string) => {
|
||||
if (!onUpdate && !onFieldUpdate) return;
|
||||
commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
if (!onUpdate) return;
|
||||
onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) });
|
||||
};
|
||||
|
||||
const addGoal = (goalId: string) => {
|
||||
if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
|
||||
commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
|
||||
if (!onUpdate || linkedGoalIds.includes(goalId)) return;
|
||||
onUpdate({ 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) => {
|
||||
@@ -358,46 +254,13 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
{onUpdate ? (
|
||||
<ProjectStatusPicker
|
||||
status={project.status}
|
||||
onChange={(status) => commitField("status", { status })}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={project.status} />
|
||||
@@ -408,87 +271,82 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<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}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</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
|
||||
<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="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => addGoal(goal.id)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{project.targetDate && (
|
||||
<PropertyRow label={<FieldLabel label="Target Date" state="idle" />}>
|
||||
<PropertyRow label="Target Date">
|
||||
<span className="text-sm">{formatDate(project.targetDate)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="py-1.5 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Workspaces</span>
|
||||
<Tooltip>
|
||||
@@ -549,51 +407,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
</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>
|
||||
@@ -705,196 +518,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<Separator />
|
||||
|
||||
<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,12 +18,6 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
envBindings: {},
|
||||
url: "",
|
||||
bootstrapPrompt: "",
|
||||
payloadTemplateJson: "",
|
||||
workspaceStrategyType: "project_primary",
|
||||
workspaceBaseRef: "",
|
||||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 80,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
|
||||
@@ -33,19 +33,12 @@ 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.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user