mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Compare commits
74 Commits
@paperclip
...
public/wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472322de24 | ||
|
|
3770e94d56 | ||
|
|
d9492f02d6 | ||
|
|
57d8d01079 | ||
|
|
345c7f4a88 | ||
|
|
521b24da3d | ||
|
|
96e03b45b9 | ||
|
|
57dcdb51af | ||
|
|
a503d2c12c | ||
|
|
21d2b075e7 | ||
|
|
92aef9bae8 | ||
|
|
5f76d03913 | ||
|
|
d3ac8722be | ||
|
|
183d71eb7c | ||
|
|
3273692944 | ||
|
|
b5935349ed | ||
|
|
4b49efa02e | ||
|
|
c2c63868e9 | ||
|
|
3a003e11cc | ||
|
|
d388255e66 | ||
|
|
80d87d3b4e | ||
|
|
21eb904a4d | ||
|
|
d62b89cadd | ||
|
|
78207304d4 | ||
|
|
c799fca313 | ||
|
|
50db379db2 | ||
|
|
56aeddfa1c | ||
|
|
42c8aca5c0 | ||
|
|
00495d3d89 | ||
|
|
a613435249 | ||
|
|
576b408682 | ||
|
|
193b7c0570 | ||
|
|
93a8b55ff8 | ||
|
|
24a553c255 | ||
|
|
2332a79e0b | ||
|
|
65af1d77a4 | ||
|
|
b0b7ec779a | ||
|
|
859c82aa12 | ||
|
|
6fd29e05ad | ||
|
|
12216b5cc6 | ||
|
|
0c525febf2 | ||
|
|
b0fe48b730 | ||
|
|
f3a9b6de21 | ||
|
|
31561724f7 | ||
|
|
c363428966 | ||
|
|
f783f66866 | ||
|
|
deec68ab16 | ||
|
|
6733a6cd7e | ||
|
|
dfbb4f1ccb | ||
|
|
9c68c1b80b | ||
|
|
e94ce47ba5 | ||
|
|
6186eba098 | ||
|
|
b83a87f42f | ||
|
|
3120c72372 | ||
|
|
7934952a77 | ||
|
|
d9574fea71 | ||
|
|
83738b45cd | ||
|
|
4a67db6a4d | ||
|
|
0704854926 | ||
|
|
1959badde7 | ||
|
|
3ff07c23d2 | ||
|
|
dec02225f1 | ||
|
|
f6f5fee200 | ||
|
|
49b9511889 | ||
|
|
1a53567cb6 | ||
|
|
9248881d42 | ||
|
|
ef978dd601 | ||
|
|
fbf9d5714f | ||
|
|
8ac064499f | ||
|
|
cbbf695c35 | ||
|
|
7e8908afa2 | ||
|
|
58d4d04e99 | ||
|
|
c672b71f7f | ||
|
|
01c5a6f198 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@paperclipai/shared": minor
|
||||
---
|
||||
|
||||
Add support for Pi local adapter in constants and onboarding UI.
|
||||
@@ -1,5 +1,26 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
- @paperclipai/adapter-claude-local@0.3.0
|
||||
- @paperclipai/adapter-codex-local@0.3.0
|
||||
- @paperclipai/adapter-cursor-local@0.3.0
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
||||
- @paperclipai/adapter-opencode-local@0.3.0
|
||||
- @paperclipai/adapter-pi-local@0.3.0
|
||||
- @paperclipai/db@0.3.0
|
||||
- @paperclipai/server@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
99
cli/src/__tests__/doctor.test.ts
Normal file
99
cli/src/__tests__/doctor.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { doctor } from "../commands/doctor.js";
|
||||
import { writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeConfig(config, configPath);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("doctor", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: true,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.warned).toBe(0);
|
||||
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
||||
});
|
||||
});
|
||||
351
cli/src/__tests__/worktree.test.ts
Normal file
351
cli/src/__tests__/worktree.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
rebindWorkspaceCwd,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeMakeCommand,
|
||||
} from "../commands/worktree.js";
|
||||
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("resolves worktree:make target paths under the user home directory", () => {
|
||||
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
||||
path.resolve(os.homedir(), "paperclip-pr-432"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
||||
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds git worktree add args for new and existing branches", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: false,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
||||
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: true,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
||||
});
|
||||
|
||||
it("builds git worktree add args with a start point", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: false,
|
||||
startPoint: "public-gh/master",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
||||
});
|
||||
|
||||
it("uses start point even when a local branch with the same name exists", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: true,
|
||||
startPoint: "origin/main",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||
});
|
||||
|
||||
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(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
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({});
|
||||
});
|
||||
|
||||
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
sourceEnvEntries: {},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the source inline secrets master key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig: buildSourceConfig(),
|
||||
sourceEnvEntries: {
|
||||
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
|
||||
},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432");
|
||||
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip/packages/db",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432/packages/db");
|
||||
});
|
||||
|
||||
it("does not rebind paths outside the source repo root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/other-project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("copies shared git hooks into a linked worktree git dir", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const worktreePath = path.join(tempRoot, "repo-feature");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
|
||||
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
|
||||
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
|
||||
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
|
||||
fs.chmodSync(sourceHookPath, 0o755);
|
||||
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
|
||||
|
||||
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
|
||||
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
|
||||
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
|
||||
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
|
||||
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
|
||||
|
||||
expect(copied).toMatchObject({
|
||||
sourceHooksPath: resolvedSourceHooksDir,
|
||||
targetHooksPath: resolvedTargetHooksDir,
|
||||
copied: true,
|
||||
});
|
||||
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
|
||||
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
|
||||
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
|
||||
} finally {
|
||||
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const fakeHome = path.join(tempRoot, "home");
|
||||
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
||||
const originalCwd = process.cwd();
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.env.HOME = fakeHome;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeMakeCommand("paperclip-make-test", {
|
||||
seed: false,
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,28 +66,40 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -95,10 +107,13 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -120,9 +135,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -130,7 +145,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return;
|
||||
if (p.isCancel(answer)) return false;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -138,10 +153,30 @@ async function maybeRepair(
|
||||
try {
|
||||
await result.repair();
|
||||
p.log.success(`Repaired: ${result.name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runRepairableCheck(input: {
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
configPath: string;
|
||||
opts: { repair?: boolean; yes?: boolean };
|
||||
}): Promise<CheckResult> {
|
||||
let result = await input.run();
|
||||
printResult(result);
|
||||
|
||||
const repaired = await maybeRepair(result, input.opts);
|
||||
if (!repaired) return result;
|
||||
|
||||
// Repairs may create/update the adjacent .env file or other local resources.
|
||||
loadPaperclipEnvFile(input.configPath);
|
||||
result = await input.run();
|
||||
printResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
|
||||
218
cli/src/commands/worktree-lib.ts
Normal file
218
cli/src/commands/worktree-lib.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
845
cli/src/commands/worktree.ts
Normal file
845
cli/src/commands/worktree.ts
Normal file
@@ -0,0 +1,845 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} 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 { eq } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
projectWorkspaces,
|
||||
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 { resolveRuntimeLikePath } from "../utils/path-resolver.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 WorktreeMakeOptions = WorktreeInitOptions & {
|
||||
startPoint?: string;
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
type GitWorkspaceInfo = {
|
||||
root: string;
|
||||
commonDir: string;
|
||||
gitDir: string;
|
||||
hooksPath: string;
|
||||
};
|
||||
|
||||
type CopiedGitHooksResult = {
|
||||
sourceHooksPath: string;
|
||||
targetHooksPath: string;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
type SeedWorktreeDatabaseResult = {
|
||||
backupSummary: string;
|
||||
reboundWorkspaces: Array<{
|
||||
name: string;
|
||||
fromCwd: string;
|
||||
toCwd: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveWorktreeMakeName(name: string): string {
|
||||
const value = nonEmpty(name);
|
||||
if (!value) {
|
||||
throw new Error("Worktree name is required.");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
||||
throw new Error(
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||
}
|
||||
|
||||
function extractExecSyncErrorMessage(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") {
|
||||
return error instanceof Error ? error.message : null;
|
||||
}
|
||||
|
||||
const stderr = "stderr" in error ? error.stderr : null;
|
||||
if (typeof stderr === "string") {
|
||||
return nonEmpty(stderr);
|
||||
}
|
||||
if (stderr instanceof Buffer) {
|
||||
return nonEmpty(stderr.toString("utf8"));
|
||||
}
|
||||
|
||||
return error instanceof Error ? nonEmpty(error.message) : null;
|
||||
}
|
||||
|
||||
function localBranchExists(cwd: string, branchName: string): boolean {
|
||||
try {
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitWorktreeAddArgs(input: {
|
||||
branchName: string;
|
||||
targetPath: string;
|
||||
branchExists: boolean;
|
||||
startPoint?: string;
|
||||
}): string[] {
|
||||
if (input.branchExists && !input.startPoint) {
|
||||
return ["worktree", "add", input.targetPath, input.branchName];
|
||||
}
|
||||
const commitish = input.startPoint ?? "HEAD";
|
||||
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
|
||||
}
|
||||
|
||||
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 detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return {
|
||||
root: path.resolve(root),
|
||||
commonDir: path.resolve(root, commonDirRaw),
|
||||
gitDir: path.resolve(root, gitDirRaw),
|
||||
hooksPath: path.resolve(root, hooksPathRaw),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
|
||||
if (!existsSync(sourceDir)) return false;
|
||||
|
||||
const entries = readdirSync(sourceDir, { withFileTypes: true });
|
||||
if (entries.length === 0) return false;
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
let copied = false;
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.resolve(sourceDir, entry.name);
|
||||
const targetPath = path.resolve(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
mkdirSync(targetPath, { recursive: true });
|
||||
copyDirectoryContents(sourcePath, targetPath);
|
||||
copied = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
symlinkSync(readlinkSync(sourcePath), targetPath);
|
||||
copied = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
try {
|
||||
chmodSync(targetPath, statSync(sourcePath).mode & 0o777);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
copied = true;
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
if (!workspace) return null;
|
||||
|
||||
const sourceHooksPath = workspace.hooksPath;
|
||||
const targetHooksPath = path.resolve(workspace.gitDir, "hooks");
|
||||
|
||||
if (sourceHooksPath === targetHooksPath) {
|
||||
return {
|
||||
sourceHooksPath,
|
||||
targetHooksPath,
|
||||
copied: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceHooksPath,
|
||||
targetHooksPath,
|
||||
copied: copyDirectoryContents(sourceHooksPath, targetHooksPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function rebindWorkspaceCwd(input: {
|
||||
sourceRepoRoot: string;
|
||||
targetRepoRoot: string;
|
||||
workspaceCwd: string;
|
||||
}): string | null {
|
||||
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
|
||||
const targetRepoRoot = path.resolve(input.targetRepoRoot);
|
||||
const workspaceCwd = path.resolve(input.workspaceCwd);
|
||||
const relative = path.relative(sourceRepoRoot, workspaceCwd);
|
||||
if (!relative || relative === "") {
|
||||
return targetRepoRoot;
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(targetRepoRoot, relative);
|
||||
}
|
||||
|
||||
async function rebindSeededProjectWorkspaces(input: {
|
||||
targetConnectionString: string;
|
||||
currentCwd: string;
|
||||
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
|
||||
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
|
||||
if (!targetRepo) return [];
|
||||
|
||||
const db = createDb(input.targetConnectionString);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
|
||||
};
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
name: projectWorkspaces.name,
|
||||
cwd: projectWorkspaces.cwd,
|
||||
})
|
||||
.from(projectWorkspaces);
|
||||
|
||||
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||
for (const row of rows) {
|
||||
const workspaceCwd = nonEmpty(row.cwd);
|
||||
if (!workspaceCwd) continue;
|
||||
|
||||
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
|
||||
if (!sourceRepo) continue;
|
||||
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
|
||||
|
||||
const reboundCwd = rebindWorkspaceCwd({
|
||||
sourceRepoRoot: sourceRepo.root,
|
||||
targetRepoRoot: targetRepo.root,
|
||||
workspaceCwd,
|
||||
});
|
||||
if (!reboundCwd) continue;
|
||||
|
||||
const normalizedCurrent = path.resolve(workspaceCwd);
|
||||
if (reboundCwd === normalizedCurrent) continue;
|
||||
if (!existsSync(reboundCwd)) continue;
|
||||
|
||||
await db
|
||||
.update(projectWorkspaces)
|
||||
.set({
|
||||
cwd: reboundCwd,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projectWorkspaces.id, row.id));
|
||||
|
||||
rebound.push({
|
||||
name: row.name,
|
||||
fromCwd: normalizedCurrent,
|
||||
toCwd: reboundCwd,
|
||||
});
|
||||
}
|
||||
|
||||
return rebound;
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
export function copySeededSecretsKey(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
sourceEnvEntries: Record<string, string>;
|
||||
targetKeyFilePath: string;
|
||||
}): void {
|
||||
if (input.sourceConfig.secrets.provider !== "local_encrypted") {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
|
||||
|
||||
const sourceInlineMasterKey =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
|
||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
|
||||
if (sourceInlineMasterKey) {
|
||||
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(input.targetKeyFilePath, 0o600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceKeyFileOverride =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
|
||||
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
|
||||
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
|
||||
|
||||
if (!existsSync(sourceKeyFilePath)) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
copyFileSync(sourceKeyFilePath, input.targetKeyFilePath);
|
||||
try {
|
||||
chmodSync(input.targetKeyFilePath, 0o600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
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<SeedWorktreeDatabaseResult> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath: input.sourceConfigPath,
|
||||
sourceConfig: input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
|
||||
});
|
||||
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);
|
||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||
targetConnectionString,
|
||||
currentCwd: input.targetPaths.cwd,
|
||||
});
|
||||
|
||||
return {
|
||||
backupSummary: formatDatabaseBackupResult(backup),
|
||||
reboundWorkspaces,
|
||||
};
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
}
|
||||
if (sourceHandle?.startedByThisProcess) {
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
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);
|
||||
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
|
||||
|
||||
let seedSummary: string | null = null;
|
||||
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||
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 {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths: paths,
|
||||
instanceId,
|
||||
seedMode,
|
||||
});
|
||||
seedSummary = seeded.backupSummary;
|
||||
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||
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 (copiedGitHooks?.copied) {
|
||||
p.log.message(
|
||||
pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`),
|
||||
);
|
||||
}
|
||||
if (seedSummary) {
|
||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||
for (const rebound of reboundWorkspaceSummary) {
|
||||
p.log.message(
|
||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
await runWorktreeInit(opts);
|
||||
}
|
||||
|
||||
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||
|
||||
const name = resolveWorktreeMakeName(nameArg);
|
||||
const sourceCwd = process.cwd();
|
||||
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||
if (existsSync(targetPath)) {
|
||||
throw new Error(`Target path already exists: ${targetPath}`);
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
if (opts.startPoint) {
|
||||
const [remote] = opts.startPoint.split("/", 1);
|
||||
try {
|
||||
execFileSync("git", ["fetch", remote], {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeArgs = resolveGitWorktreeAddArgs({
|
||||
branchName: name,
|
||||
targetPath,
|
||||
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
|
||||
startPoint: opts.startPoint,
|
||||
});
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Creating git worktree at ${targetPath}...`);
|
||||
try {
|
||||
execFileSync("git", worktreeArgs, {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Created git worktree at ${targetPath}.`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to create git worktree."));
|
||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(targetPath);
|
||||
await runWorktreeInit({
|
||||
...opts,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
program
|
||||
.command("worktree:make")
|
||||
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
||||
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
||||
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
|
||||
.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(worktreeMakeCommand);
|
||||
|
||||
worktree
|
||||
.command("init")
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeInitCommand);
|
||||
|
||||
worktree
|
||||
.command("env")
|
||||
.description("Print shell exports for the current worktree-local Paperclip instance")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
}
|
||||
@@ -25,13 +25,17 @@ function parseEnvFile(contents: string) {
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
@@ -82,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
||||
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
entries: Record<string, string>,
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -132,6 +135,7 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ That's it. On first start the server:
|
||||
|
||||
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
|
||||
|
||||
If you need to apply pending migrations manually, run:
|
||||
|
||||
```sh
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
||||
|
||||
This mode is ideal for local development and one-command installs.
|
||||
|
||||
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
||||
|
||||
@@ -124,6 +124,57 @@ 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
|
||||
# or create the git worktree and initialize it in one step:
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
|
||||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
|
||||
- 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.
|
||||
|
||||
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
|
||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Visibility | Full visibility to board and all agents in same company |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
|
||||
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
|
||||
| Agent adapters | Built-in `process` and `http` adapters |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
| Budget period | Monthly UTC calendar window |
|
||||
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
|
||||
- heartbeat trigger checks
|
||||
- stuck run detection
|
||||
- budget threshold checks
|
||||
- stale task reporting generation
|
||||
|
||||
Separate queue infrastructure is not required for V1.
|
||||
|
||||
@@ -502,7 +501,6 @@ Dashboard payload must include:
|
||||
- open/in-progress/blocked/done issue counts
|
||||
- month-to-date spend and budget utilization
|
||||
- pending approvals count
|
||||
- stale task count
|
||||
|
||||
## 10.9 Error Semantics
|
||||
|
||||
@@ -681,7 +679,6 @@ Required UX behaviors:
|
||||
- global company selector
|
||||
- quick actions: pause/resume agent, create task, approve/reject request
|
||||
- conflict toasts on atomic checkout failure
|
||||
- clear stale-task indicators
|
||||
- no silent background failures; every failed run visible in UI
|
||||
|
||||
## 15. Operational Requirements
|
||||
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
|
||||
|
||||
- add company selector and org chart view
|
||||
- add approvals and cost pages
|
||||
- add operational dashboard and stale-task surfacing
|
||||
|
||||
## Milestone 6: Hardening and Release
|
||||
|
||||
|
||||
62
doc/experimental/issue-worktree-support.md
Normal file
62
doc/experimental/issue-worktree-support.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Issue worktree support
|
||||
|
||||
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
|
||||
|
||||
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
|
||||
|
||||
- project execution workspace policy support
|
||||
- issue-level execution workspace settings
|
||||
- git worktree realization for isolated issue execution
|
||||
- optional command-based worktree provisioning
|
||||
- seeded worktree fixes for secrets key compatibility
|
||||
- seeded project workspace rebinding to the current git worktree
|
||||
|
||||
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
|
||||
|
||||
## What works today
|
||||
|
||||
- projects can carry execution workspace policy in the backend
|
||||
- issues can carry execution workspace settings in the backend
|
||||
- heartbeat execution can realize isolated git worktrees
|
||||
- runtime can run a project-defined provision command inside the derived worktree
|
||||
- seeded worktree instances can keep local-encrypted secrets working
|
||||
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
|
||||
|
||||
## Hidden UI entrypoints
|
||||
|
||||
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
|
||||
|
||||
- project settings:
|
||||
- `ui/src/components/ProjectProperties.tsx`
|
||||
- execution workspace policy controls
|
||||
- git worktree base ref / branch template / parent dir
|
||||
- provision / teardown command inputs
|
||||
|
||||
- issue creation:
|
||||
- `ui/src/components/NewIssueDialog.tsx`
|
||||
- isolated issue checkout toggle
|
||||
- defaulting issue execution workspace settings from project policy
|
||||
|
||||
- issue editing:
|
||||
- `ui/src/components/IssueProperties.tsx`
|
||||
- issue-level workspace mode toggle
|
||||
- defaulting issue execution workspace settings when project changes
|
||||
|
||||
- agent/runtime settings:
|
||||
- `ui/src/adapters/runtime-json-fields.tsx`
|
||||
- runtime services JSON field, which is part of the broader workspace-runtime support surface
|
||||
|
||||
## Why the UI is hidden
|
||||
|
||||
- the runtime behavior is still being validated
|
||||
- the workflow and operator ergonomics are not final
|
||||
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
|
||||
|
||||
## Re-enable plan
|
||||
|
||||
When this is ready to ship:
|
||||
|
||||
- re-enable the gated UI sections in the files above
|
||||
- review wording and defaults for project and issue controls
|
||||
- decide which agent/runtime settings should remain advanced-only
|
||||
- add end-to-end product-level verification for the full UI workflow
|
||||
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
|
||||
My Issues
|
||||
```
|
||||
|
||||
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
||||
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
||||
- **My Issues** — issues created by or assigned to the board operator.
|
||||
|
||||
### 3.3 Work Section
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "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",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
|
||||
@@ -272,7 +272,24 @@ export async function runChildProcess(
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
// These vars leak in when the Paperclip server itself is started from
|
||||
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
|
||||
// owned by Claude Code) or when cron inherits a contaminated shell env.
|
||||
const CLAUDE_CODE_NESTING_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION",
|
||||
"CLAUDE_CODE_PARENT_SESSION",
|
||||
] as const;
|
||||
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
||||
delete rawMerged[key];
|
||||
}
|
||||
|
||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface UsageSummary {
|
||||
|
||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId?: string | null;
|
||||
serviceName: string;
|
||||
status?: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseKey?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
providerRef?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
stopPolicy?: Record<string, unknown> | null;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
}
|
||||
@@ -208,6 +230,12 @@ export interface CreateConfigValues {
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -25,8 +25,13 @@ Core fields:
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
|
||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -31,6 +31,8 @@ Core fields:
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
@@ -40,4 +42,5 @@ Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @paperclipai/adapter-cursor-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
12
packages/adapters/openclaw-gateway/CHANGELOG.md
Normal file
12
packages/adapters/openclaw-gateway/CHANGELOG.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# @paperclipai/adapter-openclaw-gateway
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -31,6 +31,7 @@ Gateway connect identity fields:
|
||||
|
||||
Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||
@@ -39,4 +40,15 @@ Request behavior fields:
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
||||
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||
|
||||
Standard outbound payload additions:
|
||||
- paperclip (object): standardized Paperclip context added to every gateway agent request
|
||||
- paperclip.workspace (object, optional): resolved execution workspace for this run
|
||||
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
|
||||
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
|
||||
|
||||
Standard result metadata supported:
|
||||
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
|
||||
- meta.previewUrl (string, optional): shorthand single preview URL
|
||||
- meta.previewUrls (string[], optional): shorthand multiple preview URLs
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterExecutionResult,
|
||||
AdapterRuntimeServiceReport,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import crypto, { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
@@ -411,6 +415,58 @@ function appendWakeText(baseText: string, wakeText: string): string {
|
||||
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
||||
}
|
||||
|
||||
function buildStandardPaperclipPayload(
|
||||
ctx: AdapterExecutionContext,
|
||||
wakePayload: WakePayload,
|
||||
paperclipEnv: Record<string, string>,
|
||||
payloadTemplate: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const templatePaperclip = parseObject(payloadTemplate.paperclip);
|
||||
const workspace = asRecord(ctx.context.paperclipWorkspace);
|
||||
const workspaces = Array.isArray(ctx.context.paperclipWorkspaces)
|
||||
? ctx.context.paperclipWorkspaces.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
||||
: [];
|
||||
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
|
||||
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
|
||||
? ctx.context.paperclipRuntimeServiceIntents.filter(
|
||||
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
|
||||
)
|
||||
: [];
|
||||
|
||||
const standardPaperclip: Record<string, unknown> = {
|
||||
runId: ctx.runId,
|
||||
companyId: ctx.agent.companyId,
|
||||
agentId: ctx.agent.id,
|
||||
agentName: ctx.agent.name,
|
||||
taskId: wakePayload.taskId,
|
||||
issueId: wakePayload.issueId,
|
||||
issueIds: wakePayload.issueIds,
|
||||
wakeReason: wakePayload.wakeReason,
|
||||
wakeCommentId: wakePayload.wakeCommentId,
|
||||
approvalId: wakePayload.approvalId,
|
||||
approvalStatus: wakePayload.approvalStatus,
|
||||
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
||||
};
|
||||
|
||||
if (workspace) {
|
||||
standardPaperclip.workspace = workspace;
|
||||
}
|
||||
if (workspaces.length > 0) {
|
||||
standardPaperclip.workspaces = workspaces;
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) {
|
||||
standardPaperclip.workspaceRuntime = {
|
||||
...configuredWorkspaceRuntime,
|
||||
...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...templatePaperclip,
|
||||
...standardPaperclip,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUrl(input: string): URL | null {
|
||||
try {
|
||||
return new URL(input);
|
||||
@@ -835,6 +891,91 @@ function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined
|
||||
};
|
||||
}
|
||||
|
||||
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
|
||||
if (!meta) return [];
|
||||
const reports: AdapterRuntimeServiceReport[] = [];
|
||||
|
||||
const runtimeServices = Array.isArray(meta.runtimeServices)
|
||||
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
||||
: [];
|
||||
for (const entry of runtimeServices) {
|
||||
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
|
||||
if (!serviceName) continue;
|
||||
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
|
||||
const status =
|
||||
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
|
||||
? rawStatus
|
||||
: "running";
|
||||
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
|
||||
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
|
||||
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
|
||||
const scopeType =
|
||||
rawScopeType === "project_workspace" ||
|
||||
rawScopeType === "execution_workspace" ||
|
||||
rawScopeType === "agent"
|
||||
? rawScopeType
|
||||
: "run";
|
||||
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
|
||||
const healthStatus =
|
||||
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
|
||||
? rawHealth
|
||||
: status === "running"
|
||||
? "healthy"
|
||||
: "unknown";
|
||||
|
||||
reports.push({
|
||||
id: nonEmpty(entry.id),
|
||||
projectId: nonEmpty(entry.projectId),
|
||||
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
|
||||
issueId: nonEmpty(entry.issueId),
|
||||
scopeType,
|
||||
scopeId: nonEmpty(entry.scopeId),
|
||||
serviceName,
|
||||
status,
|
||||
lifecycle,
|
||||
reuseKey: nonEmpty(entry.reuseKey),
|
||||
command: nonEmpty(entry.command),
|
||||
cwd: nonEmpty(entry.cwd),
|
||||
port: parseOptionalPositiveInteger(entry.port),
|
||||
url: nonEmpty(entry.url),
|
||||
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
|
||||
ownerAgentId: nonEmpty(entry.ownerAgentId),
|
||||
stopPolicy: asRecord(entry.stopPolicy),
|
||||
healthStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const previewUrl = nonEmpty(meta.previewUrl);
|
||||
if (previewUrl) {
|
||||
reports.push({
|
||||
serviceName: "preview",
|
||||
status: "running",
|
||||
lifecycle: "ephemeral",
|
||||
scopeType: "run",
|
||||
url: previewUrl,
|
||||
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
}
|
||||
|
||||
const previewUrls = Array.isArray(meta.previewUrls)
|
||||
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
: [];
|
||||
previewUrls.forEach((url, index) => {
|
||||
reports.push({
|
||||
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
|
||||
status: "running",
|
||||
lifecycle: "ephemeral",
|
||||
scopeType: "run",
|
||||
url,
|
||||
providerRef: `${url}#${index}`,
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
});
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
function extractResultText(value: unknown): string | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) return null;
|
||||
@@ -924,9 +1065,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
||||
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
||||
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
|
||||
|
||||
const agentParams: Record<string, unknown> = {
|
||||
...payloadTemplate,
|
||||
paperclip: paperclipPayload,
|
||||
message,
|
||||
sessionKey,
|
||||
idempotencyKey: ctx.runId,
|
||||
@@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
null;
|
||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||
|
||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const agentMeta = asRecord(meta?.agentMeta);
|
||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||
const acceptedResult = asRecord(acceptedPayload?.result);
|
||||
const latestPayload = asRecord(latestResultPayload);
|
||||
const latestResult = asRecord(latestPayload?.result);
|
||||
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
||||
const mergedMeta = {
|
||||
...(acceptedMeta ?? {}),
|
||||
...(latestMeta ?? {}),
|
||||
};
|
||||
const agentMeta =
|
||||
asRecord(mergedMeta.agentMeta) ??
|
||||
asRecord(acceptedMeta?.agentMeta) ??
|
||||
asRecord(latestMeta?.agentMeta);
|
||||
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
|
||||
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
|
||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
|
||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
|
||||
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
@@ -1209,6 +1364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...(usage ? { usage } : {}),
|
||||
...(costUsd > 0 ? { costUsd } : {}),
|
||||
resultJson: asRecord(latestResultPayload),
|
||||
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
@@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
|
||||
ac.sessionKeyStrategy = "issue";
|
||||
ac.role = "operator";
|
||||
ac.scopes = ["operator.admin"];
|
||||
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
|
||||
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
return ac;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @paperclipai/adapter-opencode-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
12
packages/adapters/pi-local/CHANGELOG.md
Normal file
12
packages/adapters/pi-local/CHANGELOG.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# @paperclipai/adapter-pi-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @paperclipai/db
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/db",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
@@ -9,6 +9,9 @@ export type RunDatabaseBackupOptions = {
|
||||
retentionDays: number;
|
||||
filenamePrefix?: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
includeMigrationJournal?: boolean;
|
||||
excludeTables?: string[];
|
||||
nullifyColumns?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type RunDatabaseBackupResult = {
|
||||
@@ -17,6 +20,50 @@ export type RunDatabaseBackupResult = {
|
||||
prunedCount: number;
|
||||
};
|
||||
|
||||
export type RunDatabaseRestoreOptions = {
|
||||
connectionString: string;
|
||||
backupFile: string;
|
||||
connectTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type SequenceDefinition = {
|
||||
sequence_schema: string;
|
||||
sequence_name: string;
|
||||
data_type: string;
|
||||
start_value: string;
|
||||
minimum_value: string;
|
||||
maximum_value: string;
|
||||
increment: string;
|
||||
cycle_option: "YES" | "NO";
|
||||
owner_schema: string | null;
|
||||
owner_table: string | null;
|
||||
owner_column: string | null;
|
||||
};
|
||||
|
||||
type TableDefinition = {
|
||||
schema_name: string;
|
||||
tablename: string;
|
||||
};
|
||||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
|
||||
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
|
||||
|
||||
function sanitizeRestoreErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === "object") {
|
||||
const record = error as Record<string, unknown>;
|
||||
const firstLine = typeof record.message === "string"
|
||||
? record.message.split(/\r?\n/, 1)[0]?.trim()
|
||||
: "";
|
||||
const detail = typeof record.detail === "string" ? record.detail.trim() : "";
|
||||
const severity = typeof record.severity === "string" ? record.severity.trim() : "";
|
||||
const message = firstLine || detail || (error instanceof Error ? error.message : String(error));
|
||||
return severity ? `${severity}: ${message}` : message;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function timestamp(date: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
@@ -47,10 +94,60 @@ function formatBackupSize(sizeBytes: number): string {
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function formatSqlLiteral(value: string): string {
|
||||
const sanitized = value.replace(/\u0000/g, "");
|
||||
let tag = "$paperclip$";
|
||||
while (sanitized.includes(tag)) {
|
||||
tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`;
|
||||
}
|
||||
return `${tag}${sanitized}${tag}`;
|
||||
}
|
||||
|
||||
function normalizeTableNameSet(values: string[] | undefined): Set<string> {
|
||||
return new Set(
|
||||
(values ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNullifyColumnMap(values: Record<string, string[]> | undefined): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
if (!values) return out;
|
||||
for (const [tableName, columns] of Object.entries(values)) {
|
||||
const normalizedTable = tableName.trim();
|
||||
if (normalizedTable.length === 0) continue;
|
||||
const normalizedColumns = new Set(
|
||||
columns
|
||||
.map((column) => column.trim())
|
||||
.filter((column) => column.length > 0),
|
||||
);
|
||||
if (normalizedColumns.size > 0) {
|
||||
out.set(normalizedTable, normalizedColumns);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string): string {
|
||||
return `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function quoteQualifiedName(schemaName: string, objectName: string): string {
|
||||
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
|
||||
}
|
||||
|
||||
function tableKey(schemaName: string, tableName: string): string {
|
||||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
|
||||
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
|
||||
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
const includeMigrationJournal = opts.includeMigrationJournal === true;
|
||||
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
|
||||
try {
|
||||
@@ -58,13 +155,35 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
const lines: string[] = [];
|
||||
const emit = (line: string) => lines.push(line);
|
||||
const emitStatement = (statement: string) => {
|
||||
emit(statement);
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
};
|
||||
const emitStatementBoundary = () => {
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
};
|
||||
|
||||
emit("-- Paperclip database backup");
|
||||
emit(`-- Created: ${new Date().toISOString()}`);
|
||||
emit("");
|
||||
emit("BEGIN;");
|
||||
emitStatement("BEGIN;");
|
||||
emitStatement("SET LOCAL session_replication_role = replica;");
|
||||
emitStatement("SET LOCAL client_min_messages = warning;");
|
||||
emit("");
|
||||
|
||||
const allTables = await sql<TableDefinition[]>`
|
||||
SELECT table_schema AS schema_name, table_name AS tablename
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND (
|
||||
table_schema = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE})
|
||||
)
|
||||
ORDER BY table_schema, table_name
|
||||
`;
|
||||
const tables = allTables;
|
||||
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
|
||||
|
||||
// Get all enums
|
||||
const enums = await sql<{ typname: string; labels: string[] }[]>`
|
||||
SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
|
||||
@@ -78,23 +197,65 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
|
||||
for (const e of enums) {
|
||||
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
|
||||
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
|
||||
}
|
||||
if (enums.length > 0) emit("");
|
||||
|
||||
// Get tables in dependency order (referenced tables first)
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT c.relname AS tablename
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind = 'r'
|
||||
AND c.relname != '__drizzle_migrations'
|
||||
ORDER BY c.relname
|
||||
const allSequences = await sql<SequenceDefinition[]>`
|
||||
SELECT
|
||||
s.sequence_schema,
|
||||
s.sequence_name,
|
||||
s.data_type,
|
||||
s.start_value,
|
||||
s.minimum_value,
|
||||
s.maximum_value,
|
||||
s.increment,
|
||||
s.cycle_option,
|
||||
tblns.nspname AS owner_schema,
|
||||
tbl.relname AS owner_table,
|
||||
attr.attname AS owner_column
|
||||
FROM information_schema.sequences s
|
||||
JOIN pg_class seq ON seq.relname = s.sequence_name
|
||||
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
|
||||
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
|
||||
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
|
||||
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
|
||||
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
|
||||
WHERE s.sequence_schema = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA})
|
||||
ORDER BY s.sequence_schema, s.sequence_name
|
||||
`;
|
||||
const sequences = allSequences.filter(
|
||||
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
|
||||
);
|
||||
|
||||
const schemas = new Set<string>();
|
||||
for (const table of tables) schemas.add(table.schema_name);
|
||||
for (const seq of sequences) schemas.add(seq.sequence_schema);
|
||||
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
|
||||
if (extraSchemas.length > 0) {
|
||||
emit("-- Schemas");
|
||||
for (const schemaName of extraSchemas) {
|
||||
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequences");
|
||||
for (const seq of sequences) {
|
||||
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
|
||||
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
|
||||
emitStatement(
|
||||
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Get full CREATE TABLE DDL via column info
|
||||
for (const { tablename } of tables) {
|
||||
for (const { schema_name, tablename } of tables) {
|
||||
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
|
||||
const columns = await sql<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
@@ -108,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
SELECT column_name, data_type, udt_name, is_nullable, column_default,
|
||||
character_maximum_length, numeric_precision, numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
emit(`-- Table: ${tablename}`);
|
||||
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
|
||||
emit(`-- Table: ${schema_name}.${tablename}`);
|
||||
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
|
||||
|
||||
const colDefs: string[] = [];
|
||||
for (const col of columns) {
|
||||
@@ -149,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||||
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
|
||||
GROUP BY c.conname
|
||||
`;
|
||||
for (const p of pk) {
|
||||
@@ -157,17 +318,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
|
||||
}
|
||||
|
||||
emit(`CREATE TABLE "${tablename}" (`);
|
||||
emit(`CREATE TABLE ${qualifiedTableName} (`);
|
||||
emit(colDefs.join(",\n"));
|
||||
emit(");");
|
||||
emitStatementBoundary();
|
||||
emit("");
|
||||
}
|
||||
|
||||
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
|
||||
if (ownedSequences.length > 0) {
|
||||
emit("-- Sequence ownership");
|
||||
for (const seq of ownedSequences) {
|
||||
emitStatement(
|
||||
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Foreign keys (after all tables created)
|
||||
const fks = await sql<{
|
||||
const allForeignKeys = await sql<{
|
||||
constraint_name: string;
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_columns: string[];
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_columns: string[];
|
||||
update_rule: string;
|
||||
@@ -175,137 +350,157 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
}[]>`
|
||||
SELECT
|
||||
c.conname AS constraint_name,
|
||||
srcn.nspname AS source_schema,
|
||||
src.relname AS source_table,
|
||||
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
|
||||
tgtn.nspname AS target_schema,
|
||||
tgt.relname AS target_table,
|
||||
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
|
||||
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
|
||||
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
|
||||
JOIN pg_class tgt ON tgt.oid = c.confrelid
|
||||
JOIN pg_namespace n ON n.oid = src.relnamespace
|
||||
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
|
||||
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
|
||||
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
|
||||
WHERE c.contype = 'f' AND n.nspname = 'public'
|
||||
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY src.relname, c.conname
|
||||
WHERE c.contype = 'f' AND (
|
||||
srcn.nspname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
|
||||
ORDER BY srcn.nspname, src.relname, c.conname
|
||||
`;
|
||||
const fks = allForeignKeys.filter(
|
||||
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
|
||||
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
|
||||
);
|
||||
|
||||
if (fks.length > 0) {
|
||||
emit("-- Foreign keys");
|
||||
for (const fk of fks) {
|
||||
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
|
||||
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
|
||||
emit(
|
||||
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
emitStatement(
|
||||
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
|
||||
);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Unique constraints
|
||||
const uniques = await sql<{
|
||||
const allUniqueConstraints = await sql<{
|
||||
constraint_name: string;
|
||||
schema_name: string;
|
||||
tablename: string;
|
||||
column_names: string[];
|
||||
}[]>`
|
||||
SELECT c.conname AS constraint_name,
|
||||
n.nspname AS schema_name,
|
||||
t.relname AS tablename,
|
||||
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||||
WHERE n.nspname = 'public' AND c.contype = 'u'
|
||||
GROUP BY c.conname, t.relname
|
||||
ORDER BY t.relname, c.conname
|
||||
WHERE c.contype = 'u' AND (
|
||||
n.nspname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
GROUP BY c.conname, n.nspname, t.relname
|
||||
ORDER BY n.nspname, t.relname, c.conname
|
||||
`;
|
||||
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
|
||||
|
||||
if (uniques.length > 0) {
|
||||
emit("-- Unique constraints");
|
||||
for (const u of uniques) {
|
||||
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
|
||||
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Indexes (non-primary, non-unique-constraint)
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
SELECT indexdef
|
||||
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
|
||||
SELECT schemaname AS schema_name, tablename, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname NOT IN (
|
||||
SELECT conname FROM pg_constraint
|
||||
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
|
||||
WHERE (
|
||||
schemaname = 'public'
|
||||
OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
|
||||
)
|
||||
ORDER BY tablename, indexname
|
||||
AND indexname NOT IN (
|
||||
SELECT conname FROM pg_constraint c
|
||||
JOIN pg_namespace n ON n.oid = c.connamespace
|
||||
WHERE n.nspname = pg_indexes.schemaname
|
||||
)
|
||||
ORDER BY schemaname, tablename, indexname
|
||||
`;
|
||||
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
|
||||
|
||||
if (indexes.length > 0) {
|
||||
emit("-- Indexes");
|
||||
for (const idx of indexes) {
|
||||
emit(`${idx.indexdef};`);
|
||||
emitStatement(`${idx.indexdef};`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Dump data for each table
|
||||
for (const { tablename } of tables) {
|
||||
const count = await sql<{ n: number }[]>`
|
||||
SELECT count(*)::int AS n FROM ${sql(tablename)}
|
||||
`;
|
||||
if ((count[0]?.n ?? 0) === 0) continue;
|
||||
for (const { schema_name, tablename } of tables) {
|
||||
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
|
||||
const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
|
||||
if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue;
|
||||
|
||||
// Get column info for this table
|
||||
const cols = await sql<{ column_name: string; data_type: string }[]>`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ${tablename}
|
||||
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
|
||||
|
||||
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
|
||||
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
|
||||
|
||||
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
|
||||
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
|
||||
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
|
||||
for (const row of rows) {
|
||||
const values = row.map((val: unknown) => {
|
||||
const values = row.map((rawValue: unknown, index) => {
|
||||
const columnName = cols[index]?.column_name;
|
||||
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (typeof val === "boolean") return val ? "true" : "false";
|
||||
if (typeof val === "number") return String(val);
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`;
|
||||
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
|
||||
return `'${String(val).replace(/'/g, "''")}'`;
|
||||
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
|
||||
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
|
||||
return formatSqlLiteral(String(val));
|
||||
});
|
||||
emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`);
|
||||
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
// Sequence values
|
||||
const sequences = await sql<{ sequence_name: string }[]>`
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
WHERE sequence_schema = 'public'
|
||||
ORDER BY sequence_name
|
||||
`;
|
||||
|
||||
if (sequences.length > 0) {
|
||||
emit("-- Sequence values");
|
||||
for (const seq of sequences) {
|
||||
const val = await sql<{ last_value: string }[]>`
|
||||
SELECT last_value::text FROM ${sql(seq.sequence_name)}
|
||||
`;
|
||||
if (val[0]) {
|
||||
emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`);
|
||||
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
|
||||
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
|
||||
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
|
||||
);
|
||||
const skipSequenceValue =
|
||||
seq.owner_table !== null
|
||||
&& excludedTableNames.has(seq.owner_table);
|
||||
if (val[0] && !skipSequenceValue) {
|
||||
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
|
||||
}
|
||||
}
|
||||
emit("");
|
||||
}
|
||||
|
||||
emit("COMMIT;");
|
||||
emitStatement("COMMIT;");
|
||||
emit("");
|
||||
|
||||
// Write the backup file
|
||||
@@ -326,6 +521,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
|
||||
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
const contents = await readFile(opts.backupFile, "utf8");
|
||||
const statements = contents
|
||||
.split(STATEMENT_BREAKPOINT)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
await sql.unsafe(statement).execute();
|
||||
}
|
||||
} catch (error) {
|
||||
const statementPreview = typeof error === "object" && error !== null && typeof (error as Record<string, unknown>).query === "string"
|
||||
? String((error as Record<string, unknown>).query)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("--"))
|
||||
: null;
|
||||
throw new Error(
|
||||
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`,
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string {
|
||||
const size = formatBackupSize(result.sizeBytes);
|
||||
const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : "";
|
||||
|
||||
@@ -10,6 +10,10 @@ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
|
||||
|
||||
function createUtilitySql(url: string) {
|
||||
return postgres(url, { max: 1, onnotice: () => {} });
|
||||
}
|
||||
|
||||
function isSafeIdentifier(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
@@ -223,7 +227,7 @@ async function applyPendingMigrationsManually(
|
||||
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
|
||||
);
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
try {
|
||||
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
|
||||
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
|
||||
@@ -472,7 +476,7 @@ export async function reconcilePendingMigrationHistory(
|
||||
return { repairedMigrations: [], remainingMigrations: [] };
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
const repairedMigrations: string[] = [];
|
||||
|
||||
try {
|
||||
@@ -579,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
|
||||
}
|
||||
|
||||
export async function inspectMigrations(url: string): Promise<MigrationState> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const availableMigrations = await listMigrationFiles();
|
||||
@@ -642,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
|
||||
const initialState = await inspectMigrations(url);
|
||||
if (initialState.status === "upToDate") return;
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const db = drizzlePg(sql);
|
||||
@@ -680,7 +684,7 @@ export type MigrationBootstrapResult =
|
||||
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
|
||||
|
||||
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
|
||||
try {
|
||||
const migrationTableSchema = await discoverMigrationTableSchema(sql);
|
||||
@@ -719,7 +723,7 @@ export async function ensurePostgresDatabase(
|
||||
throw new Error(`Unsafe database name: ${databaseName}`);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const sql = createUtilitySql(url);
|
||||
try {
|
||||
const existing = await sql<{ one: number }[]>`
|
||||
select 1 as one from pg_database where datname = ${databaseName} limit 1
|
||||
|
||||
@@ -12,8 +12,10 @@ export {
|
||||
} from "./client.js";
|
||||
export {
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
formatDatabaseBackupResult,
|
||||
type RunDatabaseBackupOptions,
|
||||
type RunDatabaseBackupResult,
|
||||
type RunDatabaseRestoreOptions,
|
||||
} from "./backup-lib.js";
|
||||
export * from "./schema/index.js";
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { resolveMigrationConnection } from "./migration-runtime.js";
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
async function main(): Promise<void> {
|
||||
const resolved = await resolveMigrationConnection();
|
||||
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is required for db:migrate");
|
||||
}
|
||||
console.log(`Migrating database via ${resolved.source}`);
|
||||
|
||||
const before = await inspectMigrations(url);
|
||||
if (before.status === "upToDate") {
|
||||
console.log("No pending migrations");
|
||||
} else {
|
||||
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
|
||||
await applyPendingMigrations(url);
|
||||
try {
|
||||
const before = await inspectMigrations(resolved.connectionString);
|
||||
if (before.status === "upToDate") {
|
||||
console.log("No pending migrations");
|
||||
return;
|
||||
}
|
||||
|
||||
const after = await inspectMigrations(url);
|
||||
if (after.status !== "upToDate") {
|
||||
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
|
||||
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
|
||||
await applyPendingMigrations(resolved.connectionString);
|
||||
|
||||
const after = await inspectMigrations(resolved.connectionString);
|
||||
if (after.status !== "upToDate") {
|
||||
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
|
||||
}
|
||||
console.log("Migrations complete");
|
||||
} finally {
|
||||
await resolved.stop();
|
||||
}
|
||||
console.log("Migrations complete");
|
||||
}
|
||||
|
||||
await main();
|
||||
|
||||
134
packages/db/src/migration-runtime.ts
Normal file
134
packages/db/src/migration-runtime.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { ensurePostgresDatabase } from "./client.js";
|
||||
import { resolveDatabaseTarget } from "./runtime-config.js";
|
||||
|
||||
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;
|
||||
|
||||
export type MigrationConnection = {
|
||||
connectionString: string;
|
||||
source: string;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolveCandidates = [
|
||||
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
|
||||
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
|
||||
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
|
||||
process.cwd(),
|
||||
];
|
||||
|
||||
try {
|
||||
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
|
||||
const mod = await import(pathToFileURL(resolvedModulePath).href);
|
||||
return mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgresConnection(
|
||||
dataDir: string,
|
||||
preferredPort: number,
|
||||
): Promise<MigrationConnection> {
|
||||
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
const runningPort = readPidFilePort(postmasterPidFile);
|
||||
|
||||
if (runningPid) {
|
||||
const port = runningPort ?? preferredPort;
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
return {
|
||||
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
|
||||
source: `embedded-postgres@${port}`,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: preferredPort,
|
||||
persistent: true,
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
|
||||
return {
|
||||
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
|
||||
source: `embedded-postgres@${preferredPort}`,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMigrationConnection(): Promise<MigrationConnection> {
|
||||
const target = resolveDatabaseTarget();
|
||||
if (target.mode === "postgres") {
|
||||
return {
|
||||
connectionString: target.connectionString,
|
||||
source: target.source,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return ensureEmbeddedPostgresConnection(target.dataDir, target.port);
|
||||
}
|
||||
45
packages/db/src/migration-status.ts
Normal file
45
packages/db/src/migration-status.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { inspectMigrations } from "./client.js";
|
||||
import { resolveMigrationConnection } from "./migration-runtime.js";
|
||||
|
||||
const jsonMode = process.argv.includes("--json");
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const connection = await resolveMigrationConnection();
|
||||
|
||||
try {
|
||||
const state = await inspectMigrations(connection.connectionString);
|
||||
const payload =
|
||||
state.status === "upToDate"
|
||||
? {
|
||||
source: connection.source,
|
||||
status: "upToDate" as const,
|
||||
tableCount: state.tableCount,
|
||||
pendingMigrations: [] as string[],
|
||||
}
|
||||
: {
|
||||
source: connection.source,
|
||||
status: "needsMigrations" as const,
|
||||
tableCount: state.tableCount,
|
||||
pendingMigrations: state.pendingMigrations,
|
||||
reason: state.reason,
|
||||
};
|
||||
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === "upToDate") {
|
||||
console.log(`Database is up to date via ${payload.source}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Pending migrations via ${payload.source}: ${payload.pendingMigrations.join(", ")}`,
|
||||
);
|
||||
} finally {
|
||||
await connection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal file
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
CREATE TABLE "workspace_runtime_services" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"project_id" uuid,
|
||||
"project_workspace_id" uuid,
|
||||
"issue_id" uuid,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"service_name" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"lifecycle" text NOT NULL,
|
||||
"reuse_key" text,
|
||||
"command" text,
|
||||
"cwd" text,
|
||||
"port" integer,
|
||||
"url" text,
|
||||
"provider" text NOT NULL,
|
||||
"provider_ref" text,
|
||||
"owner_agent_id" uuid,
|
||||
"started_by_run_id" uuid,
|
||||
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"stopped_at" timestamp with time zone,
|
||||
"stop_policy" jsonb,
|
||||
"health_status" text DEFAULT 'unknown' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at");
|
||||
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal file
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;
|
||||
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,20 @@
|
||||
"when": 1772807461603,
|
||||
"tag": "0025_nasty_salo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1773089625430,
|
||||
"tag": "0026_lying_pete_wisdom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1773150731736,
|
||||
"tag": "0027_tranquil_tenebrous",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
107
packages/db/src/runtime-config.test.ts
Normal file
107
packages/db/src/runtime-config.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveDatabaseTarget } from "./runtime-config.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function writeJson(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
function writeText(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveDatabaseTarget", () => {
|
||||
it("uses DATABASE_URL from process env first", () => {
|
||||
process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip";
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
expect(target).toMatchObject({
|
||||
mode: "postgres",
|
||||
connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip",
|
||||
source: "DATABASE_URL",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses DATABASE_URL from repo-local .paperclip/.env", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
|
||||
const projectDir = path.join(tempDir, "repo");
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
process.chdir(projectDir);
|
||||
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
|
||||
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
|
||||
});
|
||||
writeText(
|
||||
path.join(projectDir, ".paperclip", ".env"),
|
||||
'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n',
|
||||
);
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
expect(target).toMatchObject({
|
||||
mode: "postgres",
|
||||
connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip",
|
||||
source: "paperclip-env",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config postgres connection string when configured", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
|
||||
const configPath = path.join(tempDir, "instance", "config.json");
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
writeJson(configPath, {
|
||||
database: {
|
||||
mode: "postgres",
|
||||
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
|
||||
},
|
||||
});
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
expect(target).toMatchObject({
|
||||
mode: "postgres",
|
||||
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
|
||||
source: "config.database.connectionString",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to embedded postgres settings from config", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
|
||||
const configPath = path.join(tempDir, "instance", "config.json");
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
writeJson(configPath, {
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "~/paperclip-test-db",
|
||||
embeddedPostgresPort: 55444,
|
||||
},
|
||||
});
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
expect(target).toMatchObject({
|
||||
mode: "embedded-postgres",
|
||||
dataDir: path.resolve(os.homedir(), "paperclip-test-db"),
|
||||
port: 55444,
|
||||
source: "embedded-postgres@55444",
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/db/src/runtime-config.ts
Normal file
267
packages/db/src/runtime-config.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const CONFIG_BASENAME = "config.json";
|
||||
const ENV_BASENAME = ".env";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
type PartialConfig = {
|
||||
database?: {
|
||||
mode?: "embedded-postgres" | "postgres";
|
||||
connectionString?: string;
|
||||
embeddedPostgresDataDir?: string;
|
||||
embeddedPostgresPort?: number;
|
||||
pgliteDataDir?: string;
|
||||
pglitePort?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedDatabaseTarget =
|
||||
| {
|
||||
mode: "postgres";
|
||||
connectionString: string;
|
||||
source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString";
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
}
|
||||
| {
|
||||
mode: "embedded-postgres";
|
||||
dataDir: string;
|
||||
port: number;
|
||||
source: `embedded-postgres@${number}`;
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
};
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipHomeDir(),
|
||||
"instances",
|
||||
resolvePaperclipInstanceId(),
|
||||
CONFIG_BASENAME,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDefaultEmbeddedPostgresDir(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db");
|
||||
}
|
||||
|
||||
function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
||||
function findConfigFileFromAncestors(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
|
||||
while (true) {
|
||||
const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
|
||||
const nextDir = path.resolve(currentDir, "..");
|
||||
if (nextDir === currentDir) return null;
|
||||
currentDir = nextDir;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePaperclipConfigPath(): string {
|
||||
if (process.env.PAPERCLIP_CONFIG?.trim()) {
|
||||
return path.resolve(process.env.PAPERCLIP_CONFIG.trim());
|
||||
}
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
|
||||
}
|
||||
|
||||
function resolvePaperclipEnvPath(configPath: string): string {
|
||||
return path.resolve(path.dirname(configPath), ENV_BASENAME);
|
||||
}
|
||||
|
||||
function parseEnvFile(contents: string): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of contents.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, key, rawValue] = match;
|
||||
const value = rawValue.trim();
|
||||
if (!value) {
|
||||
entries[key] = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(value.startsWith("\"") && value.endsWith("\"")) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
entries[key] = value.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[key] = value.replace(/\s+#.*$/, "").trim();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readEnvEntries(envPath: string): Record<string, string> {
|
||||
if (!existsSync(envPath)) return {};
|
||||
return parseEnvFile(readFileSync(envPath, "utf8"));
|
||||
}
|
||||
|
||||
function migrateLegacyConfig(raw: unknown): PartialConfig | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
|
||||
const config = { ...(raw as Record<string, unknown>) };
|
||||
const databaseRaw = config.database;
|
||||
if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const database = { ...(databaseRaw as Record<string, unknown>) };
|
||||
if (database.mode === "pglite") {
|
||||
database.mode = "embedded-postgres";
|
||||
|
||||
if (
|
||||
typeof database.embeddedPostgresDataDir !== "string" &&
|
||||
typeof database.pgliteDataDir === "string"
|
||||
) {
|
||||
database.embeddedPostgresDataDir = database.pgliteDataDir;
|
||||
}
|
||||
if (
|
||||
typeof database.embeddedPostgresPort !== "number" &&
|
||||
typeof database.pglitePort === "number" &&
|
||||
Number.isFinite(database.pglitePort)
|
||||
) {
|
||||
database.embeddedPostgresPort = database.pglitePort;
|
||||
}
|
||||
}
|
||||
|
||||
config.database = database;
|
||||
return config as PartialConfig;
|
||||
}
|
||||
|
||||
function asPositiveInt(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
const rounded = Math.trunc(value);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function readConfig(configPath: string): PartialConfig | null {
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrated = migrateLegacyConfig(parsed);
|
||||
if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) {
|
||||
throw new Error(`Invalid config at ${configPath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
const database =
|
||||
typeof migrated.database === "object" &&
|
||||
migrated.database !== null &&
|
||||
!Array.isArray(migrated.database)
|
||||
? migrated.database
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
database: database
|
||||
? {
|
||||
mode: database.mode === "postgres" ? "postgres" : "embedded-postgres",
|
||||
connectionString:
|
||||
typeof database.connectionString === "string" ? database.connectionString : undefined,
|
||||
embeddedPostgresDataDir:
|
||||
typeof database.embeddedPostgresDataDir === "string"
|
||||
? database.embeddedPostgresDataDir
|
||||
: undefined,
|
||||
embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined,
|
||||
pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined,
|
||||
pglitePort: asPositiveInt(database.pglitePort) ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDatabaseTarget(): ResolvedDatabaseTarget {
|
||||
const configPath = resolvePaperclipConfigPath();
|
||||
const envPath = resolvePaperclipEnvPath(configPath);
|
||||
const envEntries = readEnvEntries(envPath);
|
||||
|
||||
const envUrl = process.env.DATABASE_URL?.trim();
|
||||
if (envUrl) {
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString: envUrl,
|
||||
source: "DATABASE_URL",
|
||||
configPath,
|
||||
envPath,
|
||||
};
|
||||
}
|
||||
|
||||
const fileEnvUrl = envEntries.DATABASE_URL?.trim();
|
||||
if (fileEnvUrl) {
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString: fileEnvUrl,
|
||||
source: "paperclip-env",
|
||||
configPath,
|
||||
envPath,
|
||||
};
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
const connectionString = config?.database?.connectionString?.trim();
|
||||
if (config?.database?.mode === "postgres" && connectionString) {
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString,
|
||||
source: "config.database.connectionString",
|
||||
configPath,
|
||||
envPath,
|
||||
};
|
||||
}
|
||||
|
||||
const port = config?.database?.embeddedPostgresPort ?? 54329;
|
||||
const dataDir = resolveHomeAwarePath(
|
||||
config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: "embedded-postgres",
|
||||
dataDir,
|
||||
port,
|
||||
source: `embedded-postgres@${port}`,
|
||||
configPath,
|
||||
envPath,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
|
||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||
export { projects } from "./projects.js";
|
||||
export { projectWorkspaces } from "./project_workspaces.js";
|
||||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
|
||||
@@ -40,6 +40,7 @@ export const issues = pgTable(
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core";
|
||||
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
@@ -15,6 +15,7 @@ export const projects = pgTable(
|
||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
64
packages/db/src/schema/workspace_runtime_services.ts
Normal file
64
packages/db/src/schema/workspace_runtime_services.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { projects } from "./projects.js";
|
||||
import { projectWorkspaces } from "./project_workspaces.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
export const workspaceRuntimeServices = pgTable(
|
||||
"workspace_runtime_services",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
scopeType: text("scope_type").notNull(),
|
||||
scopeId: text("scope_id"),
|
||||
serviceName: text("service_name").notNull(),
|
||||
status: text("status").notNull(),
|
||||
lifecycle: text("lifecycle").notNull(),
|
||||
reuseKey: text("reuse_key"),
|
||||
command: text("command"),
|
||||
cwd: text("cwd"),
|
||||
port: integer("port"),
|
||||
url: text("url"),
|
||||
provider: text("provider").notNull(),
|
||||
providerRef: text("provider_ref"),
|
||||
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
|
||||
stopPolicy: jsonb("stop_policy").$type<Record<string, unknown>>(),
|
||||
healthStatus: text("health_status").notNull().default("unknown"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on(
|
||||
table.companyId,
|
||||
table.projectWorkspaceId,
|
||||
table.status,
|
||||
),
|
||||
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
|
||||
table.companyId,
|
||||
table.projectId,
|
||||
table.status,
|
||||
),
|
||||
runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId),
|
||||
companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on(
|
||||
table.companyId,
|
||||
table.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,12 @@
|
||||
# @paperclipai/shared
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6077ae6: Add support for Pi local adapter in constants and onboarding UI.
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/shared",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -77,6 +77,12 @@ export type {
|
||||
Project,
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
@@ -156,9 +162,11 @@ export {
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
|
||||
@@ -18,5 +18,4 @@ export interface DashboardSummary {
|
||||
monthUtilizationPercent: number;
|
||||
};
|
||||
pendingApprovals: number;
|
||||
staleTasks: number;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ export type {
|
||||
} from "./agent.js";
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
} from "./workspace-runtime.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type { Goal } from "./goal.js";
|
||||
import type { Project, ProjectWorkspace } from "./project.js";
|
||||
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||
|
||||
export interface IssueAncestorProject {
|
||||
id: string;
|
||||
@@ -73,6 +74,7 @@ export interface Issue {
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProjectStatus } from "../constants.js";
|
||||
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||
|
||||
export interface ProjectGoalRef {
|
||||
id: string;
|
||||
@@ -15,6 +16,7 @@ export interface ProjectWorkspace {
|
||||
repoRef: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
isPrimary: boolean;
|
||||
runtimeServices?: WorkspaceRuntimeService[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -33,6 +35,7 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
archivedAt: Date | null;
|
||||
|
||||
58
packages/shared/src/types/workspace-runtime.ts
Normal file
58
packages/shared/src/types/workspace-runtime.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
|
||||
|
||||
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
|
||||
|
||||
export interface ExecutionWorkspaceStrategy {
|
||||
type: ExecutionWorkspaceStrategyType;
|
||||
baseRef?: string | null;
|
||||
branchTemplate?: string | null;
|
||||
worktreeParentDir?: string | null;
|
||||
provisionCommand?: string | null;
|
||||
teardownCommand?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectExecutionWorkspacePolicy {
|
||||
enabled: boolean;
|
||||
defaultMode?: "project_primary" | "isolated";
|
||||
allowIssueOverride?: boolean;
|
||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
branchPolicy?: Record<string, unknown> | null;
|
||||
pullRequestPolicy?: Record<string, unknown> | null;
|
||||
cleanupPolicy?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionWorkspaceSettings {
|
||||
mode?: ExecutionWorkspaceMode;
|
||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeService {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId: string | null;
|
||||
serviceName: string;
|
||||
status: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle: "shared" | "ephemeral";
|
||||
reuseKey: string | null;
|
||||
command: string | null;
|
||||
cwd: string | null;
|
||||
port: number | null;
|
||||
url: string | null;
|
||||
provider: "local_process" | "adapter_managed";
|
||||
providerRef: string | null;
|
||||
ownerAgentId: string | null;
|
||||
startedByRunId: string | null;
|
||||
lastUsedAt: Date;
|
||||
startedAt: Date;
|
||||
stoppedAt: Date | null;
|
||||
stopPolicy: Record<string, unknown> | null;
|
||||
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -49,16 +49,19 @@ export {
|
||||
updateProjectSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
type CreateProject,
|
||||
type UpdateProject,
|
||||
type CreateProjectWorkspace,
|
||||
type UpdateProjectWorkspace,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
} from "./project.js";
|
||||
|
||||
export {
|
||||
createIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionWorkspaceSettingsSchema,
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
@@ -66,6 +69,7 @@ export {
|
||||
type CreateIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
type CheckoutIssue,
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
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(),
|
||||
provisionCommand: z.string().optional().nullable(),
|
||||
teardownCommand: z.string().optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const issueExecutionWorkspaceSettingsSchema = z
|
||||
.object({
|
||||
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const issueAssigneeAdapterOverridesSchema = z
|
||||
.object({
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
@@ -21,6 +40,7 @@ export const createIssueSchema = z.object({
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||
labelIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
@@ -39,6 +59,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
});
|
||||
|
||||
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
|
||||
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
|
||||
|
||||
export const checkoutIssueSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
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(),
|
||||
provisionCommand: z.string().optional().nullable(),
|
||||
teardownCommand: z.string().optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const projectExecutionWorkspacePolicySchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
|
||||
allowIssueOverride: z.boolean().optional(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
branchPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const projectWorkspaceFields = {
|
||||
name: z.string().min(1).optional(),
|
||||
cwd: z.string().min(1).optional().nullable(),
|
||||
@@ -43,6 +67,7 @@ const projectFields = {
|
||||
leadAgentId: z.string().uuid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
color: z.string().optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
};
|
||||
|
||||
@@ -56,3 +81,5 @@ export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||
export const updateProjectSchema = z.object(projectFields).partial();
|
||||
|
||||
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
|
||||
|
||||
@@ -19,6 +19,7 @@ Examples:
|
||||
|
||||
Notes:
|
||||
- Run this after pushing the stable release branch and tag.
|
||||
- Defaults to git remote public-gh.
|
||||
- If the release already exists, this script updates its title and notes.
|
||||
EOF
|
||||
}
|
||||
@@ -53,13 +54,19 @@ fi
|
||||
|
||||
tag="v$version"
|
||||
notes_file="$REPO_ROOT/releases/${tag}.md"
|
||||
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITHUB_REPO="$(github_repo_from_remote "$PUBLISH_REMOTE" || true)"
|
||||
if [ -z "$GITHUB_REPO" ]; then
|
||||
echo "Error: could not determine GitHub repository from remote $PUBLISH_REMOTE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$notes_file" ]; then
|
||||
echo "Error: release notes file not found at $notes_file." >&2
|
||||
exit 1
|
||||
@@ -71,7 +78,7 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file"
|
||||
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -80,10 +87,10 @@ if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/ta
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release edit "$tag" --title "$tag" --notes-file "$notes_file"
|
||||
if gh release view "$tag" -R "$GITHUB_REPO" >/dev/null 2>&1; then
|
||||
gh release edit "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
|
||||
echo "Updated GitHub Release $tag"
|
||||
else
|
||||
gh release create "$tag" --title "$tag" --notes-file "$notes_file"
|
||||
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
|
||||
echo "Created GitHub Release $tag"
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
|
||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||
const cliArgs = process.argv.slice(3);
|
||||
@@ -43,6 +45,121 @@ if (tailscaleAuth) {
|
||||
}
|
||||
|
||||
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
|
||||
function formatPendingMigrationSummary(migrations) {
|
||||
if (migrations.length === 0) return "none";
|
||||
return migrations.length > 3
|
||||
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
|
||||
: migrations.join(", ");
|
||||
}
|
||||
|
||||
async function runPnpm(args, options = {}) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(pnpmBin, args, {
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
env: options.env ?? process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += String(chunk);
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrBuffer += String(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal,
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function maybePreflightMigrations() {
|
||||
if (mode !== "watch") return;
|
||||
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return;
|
||||
|
||||
const status = await runPnpm(
|
||||
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
|
||||
{ env },
|
||||
);
|
||||
if (status.code !== 0) {
|
||||
process.stderr.write(status.stderr || status.stdout);
|
||||
process.exit(status.code);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(status.stdout.trim());
|
||||
} catch (error) {
|
||||
process.stderr.write(status.stderr || status.stdout);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoApply = process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
|
||||
let shouldApply = autoApply;
|
||||
|
||||
if (!autoApply) {
|
||||
if (!stdin.isTTY || !stdout.isTTY) {
|
||||
shouldApply = true;
|
||||
} else {
|
||||
const prompt = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = (
|
||||
await prompt.question(
|
||||
`Apply pending migrations (${formatPendingMigrationSummary(payload.pendingMigrations)}) now? (y/N): `,
|
||||
)
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
shouldApply = answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply) return;
|
||||
|
||||
const migrate = spawn(pnpmBin, ["db:migrate"], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
const exit = await new Promise((resolve) => {
|
||||
migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal }));
|
||||
});
|
||||
if (exit.signal) {
|
||||
process.kill(process.pid, exit.signal);
|
||||
return;
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
process.exit(exit.code);
|
||||
}
|
||||
}
|
||||
|
||||
await maybePreflightMigrations();
|
||||
|
||||
if (mode === "watch") {
|
||||
env.PAPERCLIP_MIGRATION_PROMPT = "never";
|
||||
}
|
||||
|
||||
const serverScript = mode === "watch" ? "dev:watch" : "dev";
|
||||
const child = spawn(
|
||||
pnpmBin,
|
||||
@@ -57,4 +174,3 @@ child.on("exit", (code, signal) => {
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -49,20 +49,27 @@ wait_for_http() {
|
||||
|
||||
generate_bootstrap_invite_url() {
|
||||
local bootstrap_output
|
||||
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 \
|
||||
'npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \
|
||||
'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="$(
|
||||
@@ -77,6 +84,10 @@ generate_bootstrap_invite_url() {
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
37
scripts/provision-worktree.sh
Normal file
37
scripts/provision-worktree.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
||||
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
||||
|
||||
if [[ ! -d "$base_cwd" ]]; then
|
||||
echo "Base workspace does not exist: $base_cwd" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$worktree_cwd" ]]; then
|
||||
echo "Derived worktree does not exist: $worktree_cwd" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r relative_path; do
|
||||
[[ -n "$relative_path" ]] || continue
|
||||
source_path="$base_cwd/$relative_path"
|
||||
target_path="$worktree_cwd/$relative_path"
|
||||
|
||||
[[ -d "$source_path" ]] || continue
|
||||
[[ -e "$target_path" || -L "$target_path" ]] && continue
|
||||
|
||||
mkdir -p "$(dirname "$target_path")"
|
||||
ln -s "$source_path" "$target_path"
|
||||
done < <(
|
||||
cd "$base_cwd" &&
|
||||
find . \
|
||||
-mindepth 1 \
|
||||
-maxdepth 3 \
|
||||
-type d \
|
||||
-name node_modules \
|
||||
! -path './.git/*' \
|
||||
! -path './.paperclip/*' \
|
||||
| sed 's#^\./##'
|
||||
)
|
||||
@@ -21,6 +21,35 @@ git_remote_exists() {
|
||||
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
github_repo_from_remote() {
|
||||
local remote_url
|
||||
|
||||
remote_url="$(git -C "$REPO_ROOT" remote get-url "$1" 2>/dev/null || true)"
|
||||
[ -n "$remote_url" ] || return 1
|
||||
|
||||
remote_url="${remote_url%.git}"
|
||||
remote_url="${remote_url#ssh://}"
|
||||
|
||||
node - "$remote_url" <<'NODE'
|
||||
const remoteUrl = process.argv[2];
|
||||
|
||||
const patterns = [
|
||||
/^https?:\/\/github\.com\/([^/]+\/[^/]+)$/,
|
||||
/^git@github\.com:([^/]+\/[^/]+)$/,
|
||||
/^[^:]+:([^/]+\/[^/]+)$/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = remoteUrl.match(pattern);
|
||||
if (!match) continue;
|
||||
process.stdout.write(match[1]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
NODE
|
||||
}
|
||||
|
||||
resolve_release_remote() {
|
||||
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @paperclipai/server
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
- @paperclipai/adapter-claude-local@0.3.0
|
||||
- @paperclipai/adapter-codex-local@0.3.0
|
||||
- @paperclipai/adapter-cursor-local@0.3.0
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
||||
- @paperclipai/adapter-opencode-local@0.3.0
|
||||
- @paperclipai/adapter-pi-local@0.3.0
|
||||
- @paperclipai/db@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/server",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal file
110
server/src/__tests__/approval-routes-idempotency.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalRoutes } from "../routes/approvals.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockApprovalService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
approve: vi.fn(),
|
||||
reject: vi.fn(),
|
||||
requestRevision: vi.fn(),
|
||||
resubmit: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||
listIssuesForApproval: vi.fn(),
|
||||
linkManyForApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeHireApprovalPayloadForPersistence: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
approvalService: () => mockApprovalService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => mockIssueApprovalService,
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", approvalRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("approval routes idempotent retries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
|
||||
mockApprovalService.approve.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "approved",
|
||||
payload: {},
|
||||
requestedByAgentId: "agent-1",
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/approve")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
|
||||
mockApprovalService.reject.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status: "rejected",
|
||||
payload: {},
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/approvals/approval-1/reject")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
107
server/src/__tests__/approvals-service.test.ts
Normal file
107
server/src/__tests__/approvals-service.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
create: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/agents.js", () => ({
|
||||
agentService: vi.fn(() => mockAgentService),
|
||||
}));
|
||||
|
||||
vi.mock("../services/hire-hook.js", () => ({
|
||||
notifyHireApproved: mockNotifyHireApproved,
|
||||
}));
|
||||
|
||||
type ApprovalRecord = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
requestedByAgentId: string | null;
|
||||
};
|
||||
|
||||
function createApproval(status: string): ApprovalRecord {
|
||||
return {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
status,
|
||||
payload: { agentId: "agent-1" },
|
||||
requestedByAgentId: "requester-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
|
||||
const pendingSelectResults = [...selectResults];
|
||||
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
|
||||
const returning = vi.fn(async () => updateResults);
|
||||
const updateWhere = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where: updateWhere }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
|
||||
return {
|
||||
db: { select, update },
|
||||
selectWhere,
|
||||
returning,
|
||||
};
|
||||
}
|
||||
|
||||
describe("approvalService resolution idempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
|
||||
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
|
||||
mockAgentService.terminate.mockResolvedValue(undefined);
|
||||
mockNotifyHireApproved.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("approved")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("approved");
|
||||
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
|
||||
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
|
||||
const dbStub = createDbStub(
|
||||
[[createApproval("pending")], [createApproval("rejected")]],
|
||||
[],
|
||||
);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.reject("approval-1", "board", "not now");
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.approval.status).toBe("rejected");
|
||||
expect(mockAgentService.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still performs side effects when the resolution update is newly applied", async () => {
|
||||
const approved = createApproval("approved");
|
||||
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
|
||||
|
||||
const svc = approvalService(dbStub.db as any);
|
||||
const result = await svc.approve("approval-1", "board", "ship it");
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
97
server/src/__tests__/attachment-types.test.ts
Normal file
97
server/src/__tests__/attachment-types.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseAllowedTypes,
|
||||
matchesContentType,
|
||||
DEFAULT_ALLOWED_TYPES,
|
||||
} from "../attachment-types.js";
|
||||
|
||||
describe("parseAllowedTypes", () => {
|
||||
it("returns default image types when input is undefined", () => {
|
||||
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("returns default image types when input is empty string", () => {
|
||||
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
|
||||
});
|
||||
|
||||
it("parses comma-separated types", () => {
|
||||
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
|
||||
"image/*",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lowercases entries", () => {
|
||||
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("filters empty segments", () => {
|
||||
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesContentType", () => {
|
||||
it("matches exact types", () => {
|
||||
const patterns = ["application/pdf", "image/png"];
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches /* wildcard patterns", () => {
|
||||
const patterns = ["image/*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
|
||||
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches .* wildcard patterns", () => {
|
||||
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesContentType(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
patterns,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const patterns = ["application/pdf"];
|
||||
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
|
||||
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
|
||||
});
|
||||
|
||||
it("combines exact and wildcard patterns", () => {
|
||||
const patterns = ["image/*", "application/pdf", "text/*"];
|
||||
expect(matchesContentType("image/webp", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/csv", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles plain * as allow-all wildcard", () => {
|
||||
const patterns = ["*"];
|
||||
expect(matchesContentType("image/png", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/pdf", patterns)).toBe(true);
|
||||
expect(matchesContentType("text/plain", patterns)).toBe(true);
|
||||
expect(matchesContentType("application/zip", patterns)).toBe(true);
|
||||
});
|
||||
});
|
||||
143
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
143
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "../services/execution-workspace-policy.ts";
|
||||
|
||||
describe("execution workspace policy helpers", () => {
|
||||
it("defaults new issue settings from enabled project policy", () => {
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
}),
|
||||
).toEqual({ mode: "isolated" });
|
||||
expect(
|
||||
defaultIssueExecutionWorkspaceSettingsForProject({
|
||||
enabled: true,
|
||||
defaultMode: "project_primary",
|
||||
}),
|
||||
).toEqual({ mode: "project_primary" });
|
||||
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers explicit issue mode over project policy and legacy overrides", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "project_primary" },
|
||||
issueSettings: { mode: "isolated" },
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated");
|
||||
});
|
||||
|
||||
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated" },
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("isolated");
|
||||
expect(
|
||||
resolveExecutionWorkspaceMode({
|
||||
projectPolicy: null,
|
||||
issueSettings: null,
|
||||
legacyUseProjectWorkspace: false,
|
||||
}),
|
||||
).toBe("agent_default");
|
||||
});
|
||||
|
||||
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
|
||||
const result = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: {
|
||||
workspaceStrategy: { type: "project_primary" },
|
||||
},
|
||||
projectPolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
},
|
||||
},
|
||||
issueSettings: null,
|
||||
mode: "isolated",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
|
||||
expect(result.workspaceStrategy).toEqual({
|
||||
type: "git_worktree",
|
||||
baseRef: "origin/main",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
});
|
||||
expect(result.workspaceRuntime).toEqual({
|
||||
services: [{ name: "web", command: "pnpm dev" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
|
||||
const baseConfig = {
|
||||
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
|
||||
workspaceRuntime: { services: [{ name: "web" }] },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: { enabled: true, defaultMode: "isolated" },
|
||||
issueSettings: { mode: "project_primary" },
|
||||
mode: "project_primary",
|
||||
legacyUseProjectWorkspace: null,
|
||||
}).workspaceStrategy,
|
||||
).toBeUndefined();
|
||||
|
||||
const agentDefault = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: baseConfig,
|
||||
projectPolicy: null,
|
||||
issueSettings: { mode: "agent_default" },
|
||||
mode: "agent_default",
|
||||
legacyUseProjectWorkspace: null,
|
||||
});
|
||||
expect(agentDefault.workspaceStrategy).toBeUndefined();
|
||||
expect(agentDefault.workspaceRuntime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
|
||||
expect(
|
||||
parseProjectExecutionWorkspacePolicy({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
defaultMode: "isolated",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
worktreeParentDir: ".paperclip/worktrees",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseIssueExecutionWorkspaceSettings({
|
||||
mode: "project_primary",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "project_primary",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import {
|
||||
buildOpenClawGatewayConfig,
|
||||
parseOpenClawGatewayStdoutLine,
|
||||
} from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
function buildContext(
|
||||
@@ -36,7 +39,9 @@ function buildContext(
|
||||
};
|
||||
}
|
||||
|
||||
async function createMockGatewayServer() {
|
||||
async function createMockGatewayServer(options?: {
|
||||
waitPayload?: Record<string, unknown>;
|
||||
}) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
payload: options?.waitPayload ?? {
|
||||
runId: frame.params?.runId,
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
context: {
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
wakeReason: "issue_assigned",
|
||||
issueIds: ["issue-123"],
|
||||
paperclipWorkspace: {
|
||||
cwd: "/tmp/worktrees/pap-123",
|
||||
strategy: "git_worktree",
|
||||
branchName: "pap-123-test",
|
||||
},
|
||||
paperclipWorkspaces: [
|
||||
{
|
||||
id: "workspace-1",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
],
|
||||
paperclipRuntimeServiceIntents: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
expect(payload?.paperclip).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-123",
|
||||
companyId: "company-123",
|
||||
agentId: "agent-123",
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
workspace: expect.objectContaining({
|
||||
cwd: "/tmp/worktrees/pap-123",
|
||||
strategy: "git_worktree",
|
||||
}),
|
||||
workspaces: [
|
||||
expect.objectContaining({
|
||||
id: "workspace-1",
|
||||
cwd: "/tmp/project",
|
||||
}),
|
||||
],
|
||||
workspaceRuntime: expect.objectContaining({
|
||||
services: [
|
||||
expect.objectContaining({
|
||||
name: "preview",
|
||||
lifecycle: "ephemeral",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||
});
|
||||
|
||||
it("returns adapter-managed runtime services from gateway result meta", async () => {
|
||||
const gateway = await createMockGatewayServer({
|
||||
waitPayload: {
|
||||
runId: "run-123",
|
||||
status: "ok",
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
meta: {
|
||||
runtimeServices: [
|
||||
{
|
||||
name: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await execute(
|
||||
buildContext({
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
waitTimeoutMs: 2000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.runtimeServices).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceName: "preview",
|
||||
scopeType: "run",
|
||||
url: "https://preview.example/run-123",
|
||||
providerRef: "sandbox-123",
|
||||
lifecycle: "ephemeral",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-approves pairing once and retries the run", async () => {
|
||||
const gateway = await createMockGatewayServerWithPairing();
|
||||
const logs: string[] = [];
|
||||
@@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway ui build config", () => {
|
||||
it("parses payload template and runtime services json", () => {
|
||||
const config = buildOpenClawGatewayConfig({
|
||||
adapterType: "openclaw_gateway",
|
||||
cwd: "",
|
||||
promptTemplate: "",
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
chrome: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
search: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
command: "",
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplateJson: JSON.stringify({
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
}),
|
||||
runtimeServicesJson: JSON.stringify({
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
}),
|
||||
bootstrapPrompt: "",
|
||||
maxTurnsPerRun: 0,
|
||||
heartbeatEnabled: true,
|
||||
intervalSec: 300,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway.example/ws",
|
||||
payloadTemplate: {
|
||||
agentId: "remote-agent-123",
|
||||
metadata: { team: "platform" },
|
||||
},
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "preview",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openclaw gateway testEnvironment", () => {
|
||||
it("reports missing url as failure", async () => {
|
||||
const result = await testEnvironment({
|
||||
|
||||
38
server/src/__tests__/ui-branding.test.ts
Normal file
38
server/src/__tests__/ui-branding.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
|
||||
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<head>
|
||||
<!-- PAPERCLIP_FAVICON_START -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<!-- PAPERCLIP_FAVICON_END -->
|
||||
</head>`;
|
||||
|
||||
describe("ui branding", () => {
|
||||
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
|
||||
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the worktree favicon asset set when enabled", () => {
|
||||
const links = renderFaviconLinks(true);
|
||||
expect(links).toContain("/worktree-favicon.ico");
|
||||
expect(links).toContain("/worktree-favicon.svg");
|
||||
expect(links).toContain("/worktree-favicon-32x32.png");
|
||||
expect(links).toContain("/worktree-favicon-16x16.png");
|
||||
});
|
||||
|
||||
it("rewrites the favicon block for worktree instances only", () => {
|
||||
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
|
||||
expect(branded).toContain("/worktree-favicon.svg");
|
||||
expect(branded).not.toContain('href="/favicon.svg"');
|
||||
|
||||
const defaultHtml = applyUiBranding(TEMPLATE, {});
|
||||
expect(defaultHtml).toContain('href="/favicon.svg"');
|
||||
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
|
||||
});
|
||||
});
|
||||
386
server/src/__tests__/workspace-runtime.test.ts
Normal file
386
server/src/__tests__/workspace-runtime.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureRuntimeServicesForRun,
|
||||
normalizeAdapterManagedRuntimeServices,
|
||||
realizeExecutionWorkspace,
|
||||
releaseRuntimeServicesForRun,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const leasedRunIds = new Set<string>();
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "README.md"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["checkout", "-B", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
||||
return {
|
||||
baseCwd: cwd,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
strategy: "project_primary",
|
||||
cwd,
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
Array.from(leasedRunIds).map(async (runId) => {
|
||||
await releaseRuntimeServicesForRun(runId);
|
||||
leasedRunIds.delete(runId);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const first = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(first.strategy).toBe("git_worktree");
|
||||
expect(first.created).toBe(true);
|
||||
expect(first.branchName).toBe("PAP-447-add-worktree-support");
|
||||
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
|
||||
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
|
||||
|
||||
const second = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.cwd).toBe(first.cwd);
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("runs a configured provision command inside the derived worktree", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-448",
|
||||
title: "Run provision command",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe(
|
||||
"PAP-448-run-provision-command\n",
|
||||
);
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe(
|
||||
`${repoRoot}\n`,
|
||||
);
|
||||
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe(
|
||||
"true\n",
|
||||
);
|
||||
|
||||
const reused = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-448",
|
||||
title: "Run provision command",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRuntimeServicesForRun", () => {
|
||||
it("reuses shared runtime services across runs and starts a new service after release", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
|
||||
const workspace = buildWorkspace(workspaceRoot);
|
||||
const serviceCommand =
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||
|
||||
const config = {
|
||||
workspaceRuntime: {
|
||||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command: serviceCommand,
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
expose: {
|
||||
type: "url",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "project_workspace",
|
||||
stopPolicy: {
|
||||
type: "on_run_finish",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const run1 = "run-1";
|
||||
const run2 = "run-2";
|
||||
leasedRunIds.add(run1);
|
||||
leasedRunIds.add(run2);
|
||||
|
||||
const first = await ensureRuntimeServicesForRun({
|
||||
runId: run1,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]?.reused).toBe(false);
|
||||
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
const response = await fetch(first[0]!.url!);
|
||||
expect(await response.text()).toBe("ok");
|
||||
|
||||
const second = await ensureRuntimeServicesForRun({
|
||||
runId: run2,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]?.reused).toBe(true);
|
||||
expect(second[0]?.id).toBe(first[0]?.id);
|
||||
|
||||
await releaseRuntimeServicesForRun(run1);
|
||||
leasedRunIds.delete(run1);
|
||||
await releaseRuntimeServicesForRun(run2);
|
||||
leasedRunIds.delete(run2);
|
||||
|
||||
const run3 = "run-3";
|
||||
leasedRunIds.add(run3);
|
||||
const third = await ensureRuntimeServicesForRun({
|
||||
runId: run3,
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: null,
|
||||
workspace,
|
||||
config,
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(third).toHaveLength(1);
|
||||
expect(third[0]?.reused).toBe(false);
|
||||
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
|
||||
const workspace = buildWorkspace("/tmp/project");
|
||||
const now = new Date("2026-03-09T12:00:00.000Z");
|
||||
|
||||
const first = normalizeAdapterManagedRuntimeServices({
|
||||
adapterType: "openclaw_gateway",
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Gateway Agent",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Worktree support",
|
||||
},
|
||||
workspace,
|
||||
reports: [
|
||||
{
|
||||
serviceName: "preview",
|
||||
url: "https://preview.example/run-1",
|
||||
providerRef: "sandbox-123",
|
||||
scopeType: "run",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
const second = normalizeAdapterManagedRuntimeServices({
|
||||
adapterType: "openclaw_gateway",
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Gateway Agent",
|
||||
companyId: "company-1",
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Worktree support",
|
||||
},
|
||||
workspace,
|
||||
reports: [
|
||||
{
|
||||
serviceName: "preview",
|
||||
url: "https://preview.example/run-1",
|
||||
providerRef: "sandbox-123",
|
||||
scopeType: "run",
|
||||
},
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]).toMatchObject({
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
issueId: "issue-1",
|
||||
serviceName: "preview",
|
||||
provider: "adapter_managed",
|
||||
status: "running",
|
||||
healthStatus: "healthy",
|
||||
startedByRunId: "run-1",
|
||||
});
|
||||
expect(first[0]?.id).toBe(second[0]?.id);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
@@ -32,6 +33,7 @@ export async function createApp(
|
||||
db: Db,
|
||||
opts: {
|
||||
uiMode: UiMode;
|
||||
serverPort: number;
|
||||
storageService: StorageService;
|
||||
deploymentMode: DeploymentMode;
|
||||
deploymentExposure: DeploymentExposure;
|
||||
@@ -134,7 +136,7 @@ export async function createApp(
|
||||
];
|
||||
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
||||
if (uiDist) {
|
||||
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
|
||||
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
|
||||
app.use(express.static(uiDist));
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
||||
@@ -146,12 +148,18 @@ export async function createApp(
|
||||
|
||||
if (opts.uiMode === "vite-dev") {
|
||||
const uiRoot = path.resolve(__dirname, "../../ui");
|
||||
const hmrPort = opts.serverPort + 10000;
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
appType: "spa",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
host: opts.bindHost,
|
||||
port: hmrPort,
|
||||
clientPort: hmrPort,
|
||||
},
|
||||
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
|
||||
},
|
||||
});
|
||||
@@ -161,7 +169,7 @@ export async function createApp(
|
||||
try {
|
||||
const templatePath = path.resolve(uiRoot, "index.html");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const html = await vite.transformIndexHtml(req.originalUrl, template);
|
||||
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
68
server/src/attachment-types.ts
Normal file
68
server/src/attachment-types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared attachment content-type configuration.
|
||||
*
|
||||
* By default only image types are allowed. Set the
|
||||
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
|
||||
* comma-separated list of MIME types or wildcard patterns to expand the
|
||||
* allowed set.
|
||||
*
|
||||
* Examples:
|
||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
|
||||
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
|
||||
*
|
||||
* Supported pattern syntax:
|
||||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a comma-separated list of MIME type patterns into a normalised array.
|
||||
* Returns the default image-only list when the input is empty or undefined.
|
||||
*/
|
||||
export function parseAllowedTypes(raw: string | undefined): string[] {
|
||||
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0);
|
||||
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `contentType` matches any entry in `allowedPatterns`.
|
||||
*
|
||||
* Supports exact matches ("application/pdf") and wildcard / prefix
|
||||
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
|
||||
*/
|
||||
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
|
||||
const ct = contentType.toLowerCase();
|
||||
return allowedPatterns.some((pattern) => {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
|
||||
return ct.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return ct === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Module-level singletons read once at startup ----------
|
||||
|
||||
const allowedPatterns: string[] = parseAllowedTypes(
|
||||
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
|
||||
);
|
||||
|
||||
/** Convenience wrapper using the process-level allowed list. */
|
||||
export function isAllowedContentType(contentType: string): boolean {
|
||||
return matchesContentType(contentType, allowedPatterns);
|
||||
}
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
@@ -25,7 +25,7 @@ import { createApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import { heartbeatService } from "./services/index.js";
|
||||
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
@@ -460,10 +460,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
authReady = true;
|
||||
}
|
||||
|
||||
const listenPort = await detectPort(config.port);
|
||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||
const storageService = createStorageServiceFromConfig(config);
|
||||
const app = await createApp(db as any, {
|
||||
uiMode,
|
||||
serverPort: listenPort,
|
||||
storageService,
|
||||
deploymentMode: config.deploymentMode,
|
||||
deploymentExposure: config.deploymentExposure,
|
||||
@@ -475,7 +477,6 @@ export async function startServer(): Promise<StartedServer> {
|
||||
resolveSession,
|
||||
});
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
const listenPort = await detectPort(config.port);
|
||||
|
||||
if (listenPort !== config.port) {
|
||||
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
|
||||
@@ -494,6 +495,19 @@ export async function startServer(): Promise<StartedServer> {
|
||||
deploymentMode: config.deploymentMode,
|
||||
resolveSessionFromHeaders,
|
||||
});
|
||||
|
||||
void reconcilePersistedRuntimeServicesOnStartup(db as any)
|
||||
.then((result) => {
|
||||
if (result.reconciled > 0) {
|
||||
logger.warn(
|
||||
{ reconciled: result.reconciled },
|
||||
"reconciled persisted runtime services from a previous server process",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
|
||||
});
|
||||
|
||||
if (config.heartbeatSchedulerEnabled) {
|
||||
const heartbeat = heartbeatService(db as any);
|
||||
@@ -502,7 +516,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
void heartbeat.reapOrphanedRuns().catch((err) => {
|
||||
logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
|
||||
});
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
void heartbeat
|
||||
.tickTimers(new Date())
|
||||
|
||||
@@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
|
||||
const sharedOpts = {
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
singleLine: true,
|
||||
};
|
||||
|
||||
export const logger = pino({
|
||||
|
||||
@@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) {
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Heartbeat run not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, run.companyId);
|
||||
res.json(run);
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const runId = req.params.runId as string;
|
||||
|
||||
@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
|
||||
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||
const { approval, applied } = await svc.approve(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
if (applied) {
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||
|
||||
if (approval.requestedByAgentId) {
|
||||
try {
|
||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "approval_approved",
|
||||
payload: {
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: req.actor.userId ?? "board",
|
||||
contextSnapshot: {
|
||||
source: "approval.approved",
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
taskId: primaryIssueId,
|
||||
wakeReason: "approval_approved",
|
||||
},
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_queued",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
wakeRunId: wakeRun?.id ?? null,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
approvalId: approval.id,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
},
|
||||
"failed to queue requester wakeup after approval",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_failed",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (approval.requestedByAgentId) {
|
||||
try {
|
||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "approval_approved",
|
||||
payload: {
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: req.actor.userId ?? "board",
|
||||
contextSnapshot: {
|
||||
source: "approval.approved",
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
taskId: primaryIssueId,
|
||||
wakeReason: "approval_approved",
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_queued",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
wakeRunId: wakeRun?.id ?? null,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
approvalId: approval.id,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
},
|
||||
"failed to queue requester wakeup after approval",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_failed",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) {
|
||||
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
|
||||
const { approval, applied } = await svc.reject(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.rejected",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: { type: approval.type },
|
||||
});
|
||||
if (applied) {
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.rejected",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: { type: approval.type },
|
||||
});
|
||||
}
|
||||
|
||||
res.json(redactApprovalPayload(approval));
|
||||
});
|
||||
|
||||
@@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
||||
export function assetRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = assetService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
|
||||
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
if (file.buffer.length <= 0) {
|
||||
|
||||
@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
@@ -230,6 +222,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
parentId: req.query.parentId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
q: req.query.q as string | undefined,
|
||||
});
|
||||
@@ -1067,7 +1060,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
joinRequests: joinRequestCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const hasFailedRuns = badges.failedRuns > 0;
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
@@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js";
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
const resolvableStatuses = Array.from(canResolveStatuses);
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
|
||||
|
||||
async function getExistingApproval(id: string) {
|
||||
const existing = await db
|
||||
@@ -19,6 +22,50 @@ export function approvalService(db: Db) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function resolveApproval(
|
||||
id: string,
|
||||
targetStatus: "approved" | "rejected",
|
||||
decidedByUserId: string,
|
||||
decisionNote: string | null | undefined,
|
||||
): Promise<ResolutionResult> {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
if (existing.status === targetStatus) {
|
||||
return { approval: existing, applied: false };
|
||||
}
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: targetStatus,
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) {
|
||||
return { approval: updated, applied: true };
|
||||
}
|
||||
|
||||
const latest = await getExistingApproval(id);
|
||||
if (latest.status === targetStatus) {
|
||||
return { approval: latest, applied: false };
|
||||
}
|
||||
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, status?: string) => {
|
||||
const conditions = [eq(approvals.companyId, companyId)];
|
||||
@@ -41,27 +88,16 @@ export function approvalService(db: Db) {
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
throw unprocessable("Only pending or revision requested approvals can be approved");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "approved",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"approved",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
);
|
||||
|
||||
let hireApprovedAgentId: string | null = null;
|
||||
if (updated.type === "hire_agent") {
|
||||
const now = new Date();
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
@@ -103,30 +139,18 @@ export function approvalService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
return { approval: updated, applied };
|
||||
},
|
||||
|
||||
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
throw unprocessable("Only pending or revision requested approvals can be rejected");
|
||||
}
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"rejected",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "rejected",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (updated.type === "hire_agent") {
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
@@ -134,7 +158,7 @@ export function approvalService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
return { approval: updated, applied };
|
||||
},
|
||||
|
||||
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
|
||||
@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
|
||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const staleTasks = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.status, "in_progress"),
|
||||
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const agentCounts: Record<string, number> = {
|
||||
active: 0,
|
||||
running: 0,
|
||||
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
pendingApprovals,
|
||||
staleTasks,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
143
server/src/services/execution-workspace-policy.ts
Normal file
143
server/src/services/execution-workspace-policy.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { asString, parseObject } from "../adapters/utils.js";
|
||||
|
||||
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
|
||||
|
||||
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
|
||||
const parsed = parseObject(raw);
|
||||
const type = asString(parsed.type, "");
|
||||
if (type !== "project_primary" && type !== "git_worktree") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type,
|
||||
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
|
||||
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
|
||||
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
|
||||
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
|
||||
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
|
||||
const defaultMode = asString(parsed.defaultMode, "");
|
||||
const allowIssueOverride =
|
||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||
return {
|
||||
enabled,
|
||||
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
|
||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
|
||||
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
|
||||
: {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
|
||||
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
|
||||
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
|
||||
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const mode = asString(parsed.mode, "");
|
||||
return {
|
||||
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
|
||||
? { mode }
|
||||
: {}),
|
||||
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
|
||||
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
|
||||
: {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
if (!projectPolicy?.enabled) return null;
|
||||
return {
|
||||
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceMode(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
legacyUseProjectWorkspace: boolean | null;
|
||||
}): ParsedExecutionWorkspaceMode {
|
||||
const issueMode = input.issueSettings?.mode;
|
||||
if (issueMode && issueMode !== "inherit") {
|
||||
return issueMode;
|
||||
}
|
||||
if (input.projectPolicy?.enabled) {
|
||||
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
|
||||
}
|
||||
if (input.legacyUseProjectWorkspace === false) {
|
||||
return "agent_default";
|
||||
}
|
||||
return "project_primary";
|
||||
}
|
||||
|
||||
export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
agentConfig: Record<string, unknown>;
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
mode: ParsedExecutionWorkspaceMode;
|
||||
legacyUseProjectWorkspace: boolean | null;
|
||||
}): Record<string, unknown> {
|
||||
const nextConfig = { ...input.agentConfig };
|
||||
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
|
||||
const issueHasWorkspaceOverrides = Boolean(
|
||||
input.issueSettings?.mode ||
|
||||
input.issueSettings?.workspaceStrategy ||
|
||||
input.issueSettings?.workspaceRuntime,
|
||||
);
|
||||
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
|
||||
|
||||
if (hasWorkspaceControl) {
|
||||
if (input.mode === "isolated") {
|
||||
const strategy =
|
||||
input.issueSettings?.workspaceStrategy ??
|
||||
input.projectPolicy?.workspaceStrategy ??
|
||||
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
|
||||
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
|
||||
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
|
||||
} else {
|
||||
delete nextConfig.workspaceStrategy;
|
||||
}
|
||||
|
||||
if (input.mode === "agent_default") {
|
||||
delete nextConfig.workspaceRuntime;
|
||||
} else if (input.issueSettings?.workspaceRuntime) {
|
||||
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
|
||||
} else if (input.projectPolicy?.workspaceRuntime) {
|
||||
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user