mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-14 10:56:25 +02:00
Compare commits
123 Commits
feature/wo
...
paperclip_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e8993b449 | ||
|
|
32bdcf1dca | ||
|
|
369dfa4397 | ||
|
|
905403c1af | ||
|
|
dc3f3776ea | ||
|
|
44396be7c1 | ||
|
|
c49e5e90be | ||
|
|
01180d3027 | ||
|
|
397e6d0915 | ||
|
|
778afd31b1 | ||
|
|
6fe7f7a510 | ||
|
|
088eaea0cb | ||
|
|
b1bf09970f | ||
|
|
6540084ddf | ||
|
|
cde3a8c604 | ||
|
|
57113b1075 | ||
|
|
cbe5cfe603 | ||
|
|
833ccb9921 | ||
|
|
bfbb42a9fc | ||
|
|
c4e64be4bc | ||
|
|
88b47c805c | ||
|
|
908e01655a | ||
|
|
ea54c018ad | ||
|
|
6c351cb37d | ||
|
|
ee3d8c1890 | ||
|
|
3b9da0ee95 | ||
|
|
6bfe0b8422 | ||
|
|
33c6d093ab | ||
|
|
d0b1079b9b | ||
|
|
7945e7e780 | ||
|
|
6e7266eeb4 | ||
|
|
d19ff3f4dd | ||
|
|
4435e14838 | ||
|
|
df121c61dc | ||
|
|
1f204e4d76 | ||
|
|
8194132996 | ||
|
|
f7cc292742 | ||
|
|
2efc3a3ef6 | ||
|
|
bb6e721567 | ||
|
|
e76adf6ed1 | ||
|
|
2b4d82bfdd | ||
|
|
5e9c223077 | ||
|
|
98ede67b9b | ||
|
|
f594edd39f | ||
|
|
487c86f58e | ||
|
|
b3e71ca562 | ||
|
|
ab2f9e90eb | ||
|
|
cb77b2eb7e | ||
|
|
6c9e639a68 | ||
|
|
6e4694716b | ||
|
|
87b8e21701 | ||
|
|
dd5d2c7c92 | ||
|
|
e168dc7b97 | ||
|
|
4670f60d3e | ||
|
|
472322de24 | ||
|
|
3770e94d56 | ||
|
|
d9492f02d6 | ||
|
|
57d8d01079 | ||
|
|
345c7f4a88 | ||
|
|
521b24da3d | ||
|
|
96e03b45b9 | ||
|
|
57dcdb51af | ||
|
|
a503d2c12c | ||
|
|
21d2b075e7 | ||
|
|
426b16987a | ||
|
|
92aef9bae8 | ||
|
|
5f76d03913 | ||
|
|
d3ac8722be | ||
|
|
183d71eb7c | ||
|
|
3273692944 | ||
|
|
b5935349ed | ||
|
|
4b49efa02e | ||
|
|
c2c63868e9 | ||
|
|
9d2800e691 | ||
|
|
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 | ||
|
|
6733a6cd7e | ||
|
|
dfbb4f1ccb | ||
|
|
6956dad53a | ||
|
|
e9fc403b94 | ||
|
|
8eb8b16047 | ||
|
|
4e5f67ef96 | ||
|
|
ec445e4cc9 | ||
|
|
af97259a9c | ||
|
|
9c68c1b80b | ||
|
|
e94ce47ba5 | ||
|
|
6186eba098 | ||
|
|
b83a87f42f | ||
|
|
3120c72372 | ||
|
|
1959badde7 | ||
|
|
3ff07c23d2 | ||
|
|
dec02225f1 | ||
|
|
f6f5fee200 | ||
|
|
8a7b7a2383 | ||
|
|
1a75e6d15c | ||
|
|
5e18ccace7 | ||
|
|
f99f174e2d |
@@ -33,7 +33,7 @@ Use this skill when leadership asks for:
|
||||
|
||||
Before proceeding, verify all of the following:
|
||||
|
||||
1. `skills/release-changelog/SKILL.md` exists and is usable.
|
||||
1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
|
||||
2. The repo working tree is clean, including untracked files.
|
||||
3. There are commits since the last stable tag.
|
||||
4. The release SHA has passed the verification gate or is about to.
|
||||
@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
||||
<!-- TODO: add CONTRIBUTING.md -->
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copySeededSecretsKey } from "../commands/worktree.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
rebindWorkspaceCwd,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
} from "../commands/worktree.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
@@ -14,6 +23,20 @@ import {
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
function buildSourceConfig(): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
@@ -77,6 +100,58 @@ describe("worktree helpers", () => {
|
||||
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");
|
||||
@@ -109,6 +184,7 @@ describe("worktree helpers", () => {
|
||||
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'");
|
||||
});
|
||||
|
||||
@@ -128,7 +204,11 @@ describe("worktree helpers", () => {
|
||||
|
||||
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
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");
|
||||
@@ -147,6 +227,16 @@ describe("worktree helpers", () => {
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
||||
} finally {
|
||||
if (originalInlineMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
|
||||
}
|
||||
if (originalKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -171,4 +261,145 @@ describe("worktree helpers", () => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the current agent jwt secret into the worktree env file", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalJwtSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
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 homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
|
||||
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.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);
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -45,6 +51,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
|
||||
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
||||
} else {
|
||||
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
||||
p.log.message(
|
||||
pc.dim("Restart the Paperclip server for this change to take effect."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
||||
|
||||
@@ -202,6 +202,7 @@ export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<strin
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
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";
|
||||
@@ -47,6 +62,10 @@ type WorktreeInitOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||
startPoint?: string;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
@@ -74,10 +93,98 @@ type EmbeddedPostgresHandle = {
|
||||
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 isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
|
||||
const currentConfigPath = process.env.PAPERCLIP_CONFIG;
|
||||
if (!currentConfigPath || currentConfigPath.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -133,6 +240,180 @@ function detectGitBranchName(cwd: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
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"));
|
||||
@@ -167,9 +448,10 @@ export function copySeededSecretsKey(input: {
|
||||
|
||||
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
|
||||
|
||||
const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath);
|
||||
const sourceInlineMasterKey =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
|
||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
|
||||
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null);
|
||||
if (sourceInlineMasterKey) {
|
||||
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
|
||||
encoding: "utf8",
|
||||
@@ -185,7 +467,7 @@ export function copySeededSecretsKey(input: {
|
||||
|
||||
const sourceKeyFileOverride =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
|
||||
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null);
|
||||
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
|
||||
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
|
||||
|
||||
@@ -260,7 +542,7 @@ async function seedWorktreeDatabase(input: {
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
}): Promise<string> {
|
||||
}): Promise<SeedWorktreeDatabaseResult> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
@@ -308,8 +590,15 @@ async function seedWorktreeDatabase(input: {
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||
targetConnectionString,
|
||||
currentCwd: input.targetPaths.cwd,
|
||||
});
|
||||
|
||||
return formatDatabaseBackupResult(backup);
|
||||
return {
|
||||
backupSummary: formatDatabaseBackupResult(backup),
|
||||
reboundWorkspaces,
|
||||
};
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
@@ -320,10 +609,7 @@ async function seedWorktreeDatabase(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
|
||||
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
@@ -365,11 +651,23 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
|
||||
const existingAgentJwtSecret =
|
||||
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
|
||||
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
...buildWorktreeEnvEntries(paths),
|
||||
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
||||
},
|
||||
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(
|
||||
@@ -379,7 +677,7 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||
try {
|
||||
seedSummary = await seedWorktreeDatabase({
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
@@ -387,6 +685,8 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
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."));
|
||||
@@ -399,9 +699,19 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
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(
|
||||
@@ -410,6 +720,85 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -433,6 +822,23 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
|
||||
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")
|
||||
|
||||
@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -132,12 +132,15 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
|
||||
|
||||
```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
|
||||
|
||||
@@ -149,6 +152,8 @@ Seed modes:
|
||||
|
||||
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
|
||||
@@ -157,17 +162,75 @@ paperclipai worktree env
|
||||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
Useful options:
|
||||
### Worktree CLI Reference
|
||||
|
||||
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--name <name>` | Display name used to derive the instance id |
|
||||
| `--instance <id>` | Explicit isolated instance id |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id (default: `default`) |
|
||||
| `--server-port <port>` | Preferred server port |
|
||||
| `--db-port <port>` | Preferred embedded Postgres port |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Skip database seeding from the source instance |
|
||||
| `--force` | Replace existing repo-local config and isolated instance data |
|
||||
|
||||
Examples:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--start-point <ref>` | Remote ref to base the new branch on (e.g. `origin/main`) |
|
||||
| `--instance <id>` | Explicit isolated instance id |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id (default: `default`) |
|
||||
| `--server-port <port>` | Preferred server port |
|
||||
| `--db-port <port>` | Preferred embedded Postgres port |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Skip database seeding from the source instance |
|
||||
| `--force` | Replace existing repo-local config and isolated instance data |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
pnpm paperclipai worktree:make my-feature --start-point origin/main
|
||||
pnpm paperclipai worktree:make experiment --no-seed
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-c, --config <path>` | Path to config file |
|
||||
| `--json` | Print JSON instead of shell exports |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree env
|
||||
pnpm paperclipai worktree env --json
|
||||
eval "$(pnpm paperclipai worktree env)"
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -58,7 +58,7 @@ From the release worktree:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
### 3. Verify and publish a canary
|
||||
@@ -418,5 +418,5 @@ If the release already exists, the script updates it.
|
||||
## Related Docs
|
||||
|
||||
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
|
||||
- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow
|
||||
- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
|
||||
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||
|
||||
@@ -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
|
||||
|
||||
45
docs/adapters/gemini-local.md
Normal file
45
docs/adapters/gemini-local.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Gemini Local
|
||||
summary: Gemini CLI local adapter setup and configuration
|
||||
---
|
||||
|
||||
The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gemini CLI installed (`gemini` command available)
|
||||
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||
| `model` | string | No | Gemini model to use. Defaults to `auto`. |
|
||||
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
|
||||
|
||||
## Session Persistence
|
||||
|
||||
The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
|
||||
|
||||
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
|
||||
|
||||
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
- Gemini CLI is installed and accessible
|
||||
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||
- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
|
||||
- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness
|
||||
@@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
@@ -54,7 +55,7 @@ Three registries consume these modules:
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
@@ -21,3 +22,9 @@ export type {
|
||||
CLIAdapterModule,
|
||||
CreateConfigValues,
|
||||
} from "./types.js";
|
||||
export {
|
||||
REDACTED_HOME_PATH_USER,
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
|
||||
81
packages/adapter-utils/src/log-redaction.ts
Normal file
81
packages/adapter-utils/src/log-redaction.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { TranscriptEntry } from "./types.js";
|
||||
|
||||
export const REDACTED_HOME_PATH_USER = "[]";
|
||||
|
||||
const HOME_PATH_PATTERNS = [
|
||||
{
|
||||
regex: /\/Users\/[^/\\\s]+/g,
|
||||
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /\/home\/[^/\\\s]+/g,
|
||||
replace: `/home/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
|
||||
replace: `$1${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegments(text: string): string {
|
||||
let result = text;
|
||||
for (const pattern of HOME_PATH_PATTERNS) {
|
||||
result = result.replace(pattern.regex, pattern.replace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
|
||||
if (typeof value === "string") {
|
||||
return redactHomePathUserSegments(value) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactHomePathUserSegmentsInValue(entry);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
|
||||
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
|
||||
switch (entry.kind) {
|
||||
case "assistant":
|
||||
case "thinking":
|
||||
case "user":
|
||||
case "stderr":
|
||||
case "system":
|
||||
case "stdout":
|
||||
return { ...entry, text: redactHomePathUserSegments(entry.text) };
|
||||
case "tool_call":
|
||||
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
|
||||
case "tool_result":
|
||||
return { ...entry, content: redactHomePathUserSegments(entry.content) };
|
||||
case "init":
|
||||
return {
|
||||
...entry,
|
||||
model: redactHomePathUserSegments(entry.model),
|
||||
sessionId: redactHomePathUserSegments(entry.sessionId),
|
||||
};
|
||||
case "result":
|
||||
return {
|
||||
...entry,
|
||||
text: redactHomePathUserSegments(entry.text),
|
||||
subtype: redactHomePathUserSegments(entry.subtype),
|
||||
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
|
||||
};
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -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,8 +72,17 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
@@ -167,7 +197,7 @@ export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
@@ -208,6 +238,12 @@ export interface CreateConfigValues {
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
|
||||
@@ -25,8 +25,13 @@ Core fields:
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
|
||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof block.name === "string" ? block.name : "unknown",
|
||||
toolUseId:
|
||||
typeof block.id === "string"
|
||||
? block.id
|
||||
: typeof block.tool_use_id === "string"
|
||||
? block.tool_use_id
|
||||
: undefined,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,4 +1,8 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
type TranscriptEntry,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "string") return redactHomePathUserSegments(value);
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
return redactHomePathUserSegments(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,22 +61,24 @@ function parseCommandExecutionItem(
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
const safeCommand = redactHomePathUserSegments(command);
|
||||
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: id || command || "command_execution",
|
||||
input: {
|
||||
id,
|
||||
command,
|
||||
command: safeCommand,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (command) lines.push(`command: ${command}`);
|
||||
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||
if (output) {
|
||||
@@ -103,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
|
||||
@@ -125,13 +131,13 @@ function parseCodexItem(
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "assistant", ts, text }];
|
||||
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "thinking", ts, text }];
|
||||
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||
}
|
||||
|
||||
@@ -147,8 +153,9 @@ function parseCodexItem(
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(item.name, "unknown"),
|
||||
input: item.input ?? {},
|
||||
name: redactHomePathUserSegments(asString(item.name, "unknown")),
|
||||
toolUseId: asString(item.id),
|
||||
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -160,24 +167,28 @@ function parseCodexItem(
|
||||
asString(item.result) ||
|
||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
|
||||
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
|
||||
}
|
||||
|
||||
if (itemType === "error" && phase === "completed") {
|
||||
const text = errorText(item.message ?? item.error ?? item);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
|
||||
}
|
||||
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
@@ -187,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: asString(parsed.model, "codex"),
|
||||
sessionId: threadId,
|
||||
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
|
||||
sessionId: redactHomePathUserSegments(threadId),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -210,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
|
||||
isError: parsed.is_error === true,
|
||||
errors: Array.isArray(parsed.errors)
|
||||
? parsed.errors.map(errorText).filter(Boolean)
|
||||
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
|
||||
: [],
|
||||
}];
|
||||
}
|
||||
@@ -232,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype, "turn.failed"),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
|
||||
isError: true,
|
||||
errors: message ? [message] : [],
|
||||
errors: message ? [redactHomePathUserSegments(message)] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
return [{ kind: "stderr", ts, text: message || line }];
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
|
||||
@@ -142,6 +142,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
toolUseId:
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
undefined,
|
||||
input,
|
||||
});
|
||||
continue;
|
||||
@@ -199,6 +205,7 @@ function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): T
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
toolUseId: callId,
|
||||
input,
|
||||
}];
|
||||
}
|
||||
|
||||
51
packages/adapters/gemini-local/package.json
Normal file
51
packages/adapters/gemini-local/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-gemini-local",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
208
packages/adapters/gemini-local/src/cli/format-event.ts
Normal file
208
packages/adapters/gemini-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(colorize(`${prefix}: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
||||
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
||||
const cached = asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
}
|
||||
|
||||
export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printTextMessage("assistant", pc.green, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printTextMessage("user", pc.gray, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
const [toolName] = toolCall ? Object.keys(toolCall) : [];
|
||||
if (!toolCall || !toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}`));
|
||||
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
|
||||
return;
|
||||
}
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
|
||||
return;
|
||||
}
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
printUsage(parsed);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true;
|
||||
if (subtype || isError) {
|
||||
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
1
packages/adapters/gemini-local/src/cli/index.ts
Normal file
1
packages/adapters/gemini-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printGeminiStreamEvent } from "./format-event.js";
|
||||
47
packages/adapters/gemini-local/src/index.ts
Normal file
47
packages/adapters/gemini-local/src/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const type = "gemini_local";
|
||||
export const label = "Gemini CLI (local)";
|
||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
|
||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# gemini_local agent configuration
|
||||
|
||||
Adapter: gemini_local
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run the Gemini CLI locally on the host machine
|
||||
- You want Gemini chat sessions resumed across heartbeats with --resume
|
||||
- You want Paperclip skills injected locally without polluting the global environment
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use http or openclaw_gateway)
|
||||
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||
- Gemini CLI is not installed on the machine that runs Paperclip
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Gemini model id. Defaults to auto.
|
||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||
- command (string, optional): defaults to "gemini"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs use positional prompt arguments, not stdin.
|
||||
- Sessions resume with --resume when stored session cwd matches the current cwd.
|
||||
- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
|
||||
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
|
||||
`;
|
||||
436
packages/adapters/gemini-local/src/server/execute.ts
Normal file
436
packages/adapters/gemini-local/src/server/execute.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
buildPaperclipEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
redactEnvForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import {
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
isGeminiUnknownSessionError,
|
||||
parseGeminiJsonl,
|
||||
} from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||
return [
|
||||
"Paperclip API access note:",
|
||||
"Use run_shell_command with curl to make Paperclip API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
|
||||
* both its auth credentials and the injected skills in the real home directory.
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<void> {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = geminiSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureGeminiSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveGeminiBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
|
||||
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
args.push("--approval-mode", "yolo");
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push(prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "gemini_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, index) => (
|
||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||
)),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
parsed: parseGeminiJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseGeminiJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: attempt.parsed.resultEvent,
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
});
|
||||
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
||||
|
||||
// On retry, don't fall back to old session ID — the old session was stale
|
||||
const canFallbackToRuntimeSession = !isRetry;
|
||||
const resolvedSessionId = attempt.parsed.sessionId
|
||||
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const structuredFailure = attempt.parsed.resultEvent
|
||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||
: null;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
structuredFailure ||
|
||||
stderrLine ||
|
||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "google",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: attempt.parsed.resultEvent ?? {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
question: attempt.parsed.question,
|
||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
70
packages/adapters/gemini-local/src/server/index.ts
Normal file
70
packages/adapters/gemini-local/src/server/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseGeminiJsonl,
|
||||
isGeminiUnknownSessionError,
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
} from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
263
packages/adapters/gemini-local/src/server/parse.ts
Normal file
263
packages/adapters/gemini-local/src/server/parse.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function collectMessageText(message: unknown): string[] {
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
const record = parseObject(message);
|
||||
const direct = asString(record.text, "").trim();
|
||||
const lines: string[] = direct ? [direct] : [];
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
const type = asString(part.type, "").trim();
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readSessionId(event: Record<string, unknown>): string | null {
|
||||
return (
|
||||
asString(event.session_id, "").trim() ||
|
||||
asString(event.sessionId, "").trim() ||
|
||||
asString(event.sessionID, "").trim() ||
|
||||
asString(event.checkpoint_id, "").trim() ||
|
||||
asString(event.thread_id, "").trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "") ||
|
||||
asString(rec.error, "") ||
|
||||
asString(rec.code, "") ||
|
||||
asString(rec.detail, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function accumulateUsage(
|
||||
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
||||
usageRaw: unknown,
|
||||
) {
|
||||
const usage = parseObject(usageRaw);
|
||||
const usageMetadata = parseObject(usage.usageMetadata);
|
||||
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
||||
|
||||
target.inputTokens += asNumber(
|
||||
source.input_tokens,
|
||||
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
||||
);
|
||||
target.cachedInputTokens += asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||
);
|
||||
target.outputTokens += asNumber(
|
||||
source.output_tokens,
|
||||
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseGeminiJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let costUsd: number | null = null;
|
||||
let resultEvent: Record<string, unknown> | null = null;
|
||||
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSessionId = readSessionId(event);
|
||||
if (foundSessionId) sessionId = foundSessionId;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectMessageText(event.message));
|
||||
const messageObj = parseObject(event.message);
|
||||
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
if (asString(part.type, "").trim() === "question") {
|
||||
question = {
|
||||
prompt: asString(part.prompt, "").trim(),
|
||||
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||
const choice = parseObject(choiceRaw);
|
||||
return {
|
||||
key: asString(choice.key, "").trim(),
|
||||
label: asString(choice.label, "").trim(),
|
||||
description: asString(choice.description, "").trim() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
break; // only one question per message
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultEvent = event;
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
const resultText =
|
||||
asString(event.result, "").trim() ||
|
||||
asString(event.text, "").trim() ||
|
||||
asString(event.response, "").trim();
|
||||
if (resultText && messages.length === 0) messages.push(resultText);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
if (isError) {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd,
|
||||
errorMessage,
|
||||
resultEvent,
|
||||
question,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||
const messages: string[] = [];
|
||||
const errorMsg = asString(parsed.error, "").trim();
|
||||
if (errorMsg) messages.push(errorMsg);
|
||||
|
||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string") {
|
||||
const msg = entry.trim();
|
||||
if (msg) messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||
if (msg) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
messages.push(JSON.stringify(obj));
|
||||
} catch {
|
||||
// skip non-serializable entry
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
||||
const status = asString(parsed.status, "");
|
||||
const errors = extractGeminiErrorMessages(parsed);
|
||||
|
||||
const detail = errors[0] ?? "";
|
||||
const parts = ["Gemini run failed"];
|
||||
if (status) parts.push(`status=${status}`);
|
||||
if (detail) parts.push(detail);
|
||||
return parts.length > 1 ? parts.join(": ") : null;
|
||||
}
|
||||
|
||||
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
||||
|
||||
export function detectGeminiAuthRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresAuth: boolean } {
|
||||
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
||||
const messages = [...errors, input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
||||
return { requiresAuth };
|
||||
}
|
||||
|
||||
export function isGeminiTurnLimitResult(
|
||||
parsed: Record<string, unknown> | null | undefined,
|
||||
exitCode?: number | null,
|
||||
): boolean {
|
||||
if (exitCode === 53) return true;
|
||||
if (!parsed) return false;
|
||||
|
||||
const status = asString(parsed.status, "").trim().toLowerCase();
|
||||
if (status === "turn_limit" || status === "max_turns") return true;
|
||||
|
||||
const error = asString(parsed.error, "").trim();
|
||||
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
||||
}
|
||||
223
packages/adapters/gemini-local/src/server/test.ts
Normal file
223
packages/adapters/gemini-local/src/server/test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asString,
|
||||
asStringArray,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "gemini");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "gemini_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "gemini_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
if (
|
||||
isNonEmpty(configGeminiApiKey) ||
|
||||
isNonEmpty(hostGeminiApiKey) ||
|
||||
isNonEmpty(configGoogleApiKey) ||
|
||||
isNonEmpty(hostGoogleApiKey) ||
|
||||
hasGca
|
||||
) {
|
||||
const source = hasGca
|
||||
? "Google account login (GCA)"
|
||||
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
|
||||
? "adapter config env"
|
||||
: "server environment";
|
||||
checks.push({
|
||||
code: "gemini_api_key_present",
|
||||
level: "info",
|
||||
message: "Gemini API credentials are set for CLI authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_api_key_missing",
|
||||
level: "info",
|
||||
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "gemini")) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `gemini`.",
|
||||
detail: command,
|
||||
hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
onLog: async () => { },
|
||||
},
|
||||
);
|
||||
const parsed = parseGeminiJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: parsed.resultEvent,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Gemini hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Gemini hello probe succeeded."
|
||||
: "Gemini probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (authMeta.requiresAuth) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Gemini CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Gemini hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
8
packages/adapters/gemini-local/src/server/utils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
75
packages/adapters/gemini-local/src/ui/build-config.ts
Normal file
75
packages/adapters/gemini-local/src/ui/build-config.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
2
packages/adapters/gemini-local/src/ui/index.ts
Normal file
2
packages/adapters/gemini-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseGeminiStdoutLine } from "./parse-stdout.js";
|
||||
export { buildGeminiLocalConfig } from "./build-config.js";
|
||||
274
packages/adapters/gemini-local/src/ui/parse-stdout.ts
Normal file
274
packages/adapters/gemini-local/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind, ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind, ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text" && type !== "content") continue;
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind, ts, text });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind: "assistant", ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
input: part.input ?? part.arguments ?? part.args ?? {},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const toolUseId =
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
"tool_result";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: contentText,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
|
||||
}];
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const result = payload.result ?? payload.output ?? payload.error;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: callId,
|
||||
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
|
||||
}
|
||||
|
||||
function readSessionId(parsed: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id) ||
|
||||
asString(parsed.thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
function readUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
return {
|
||||
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
|
||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||
cachedTokens: asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId = readSessionId(parsed);
|
||||
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
return parseAssistantMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
return collectTextEntries(parsed.message, ts, "user");
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
return parseTopLevelToolEvent(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = readUsage(parsed);
|
||||
const errors = parsed.is_error === true
|
||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||
: [];
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype: asString(parsed.subtype, "result"),
|
||||
isError: parsed.is_error === true,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
8
packages/adapters/gemini-local/tsconfig.json
Normal file
8
packages/adapters/gemini-local/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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,6 +1065,7 @@ 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,
|
||||
@@ -1188,12 +1330,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 +1363,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;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEn
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
toolUseId: asString(part.callID) || asString(part.id) || undefined,
|
||||
input,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
108
packages/db/src/runtime-config.test.ts
Normal file
108
packages/db/src/runtime-config.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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);
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
|
||||
@@ -77,6 +77,12 @@ export type {
|
||||
Project,
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
WorkspaceRuntimeService,
|
||||
ExecutionWorkspaceStrategyType,
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
@@ -93,6 +99,7 @@ export type {
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
LiveEvent,
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
@@ -156,9 +163,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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
HeartbeatInvocationSource,
|
||||
HeartbeatRunStatus,
|
||||
WakeupTriggerDetail,
|
||||
@@ -105,3 +107,20 @@ export interface AgentWakeupRequest {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface InstanceSchedulerHeartbeatAgent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyIssuePrefix: string;
|
||||
agentName: string;
|
||||
agentUrlKey: string;
|
||||
role: AgentRole;
|
||||
title: string | null;
|
||||
status: AgentStatus;
|
||||
adapterType: string;
|
||||
intervalSec: number;
|
||||
heartbeatEnabled: boolean;
|
||||
schedulerActive: boolean;
|
||||
lastHeartbeatAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -40,6 +48,7 @@ export type {
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
} from "./heartbeat.js";
|
||||
export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -7,61 +7,109 @@
|
||||
* working tree (not just staged changes).
|
||||
*
|
||||
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
|
||||
* If the file is missing, the check passes silently — other developers
|
||||
* on the project won't have this list, and that's fine.
|
||||
* If the file is missing, the check still uses the active local username when
|
||||
* available. If username detection fails, the check degrades gracefully.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
|
||||
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
|
||||
const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt");
|
||||
|
||||
if (!existsSync(tokensFile)) {
|
||||
console.log(" ℹ Forbidden tokens list not found — skipping check.");
|
||||
process.exit(0);
|
||||
function uniqueNonEmpty(values) {
|
||||
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
|
||||
}
|
||||
|
||||
const tokens = readFileSync(tokensFile, "utf8")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
|
||||
const candidates = [env.USER, env.LOGNAME, env.USERNAME];
|
||||
|
||||
if (tokens.length === 0) {
|
||||
console.log(" ℹ Forbidden tokens list is empty — skipping check.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Use git grep to search tracked files only (avoids node_modules, dist, etc.)
|
||||
let found = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const result = execSync(
|
||||
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
|
||||
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
if (result.trim()) {
|
||||
if (!found) {
|
||||
console.error("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
}
|
||||
found = true;
|
||||
// Print matches but DO NOT print which token was matched (avoids leaking the list)
|
||||
const lines = result.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
console.error(` ${line}`);
|
||||
}
|
||||
}
|
||||
candidates.push(osModule.userInfo().username);
|
||||
} catch {
|
||||
// git grep returns exit code 1 when no matches — that's fine
|
||||
// Some environments do not expose userInfo; env vars are enough fallback.
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
console.error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(" ✓ No forbidden tokens found.");
|
||||
export function readForbiddenTokensFile(tokensFile) {
|
||||
if (!existsSync(tokensFile)) return [];
|
||||
|
||||
return readFileSync(tokensFile, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) {
|
||||
return uniqueNonEmpty([
|
||||
...resolveDynamicForbiddenTokens(env, osModule),
|
||||
...readForbiddenTokensFile(tokensFile),
|
||||
]);
|
||||
}
|
||||
|
||||
export function runForbiddenTokenCheck({
|
||||
repoRoot,
|
||||
tokens,
|
||||
exec = execSync,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
if (tokens.length === 0) {
|
||||
log(" ℹ Forbidden tokens list is empty — skipping check.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
|
||||
for (const token of tokens) {
|
||||
try {
|
||||
const result = exec(
|
||||
`git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`,
|
||||
{ encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
if (result.trim()) {
|
||||
if (!found) {
|
||||
error("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
}
|
||||
found = true;
|
||||
const lines = result.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
error(` ${line}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// git grep returns exit code 1 when no matches — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
log(" ✓ No forbidden tokens found.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveRepoPaths(exec = execSync) {
|
||||
const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
|
||||
const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
|
||||
return {
|
||||
repoRoot,
|
||||
tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { repoRoot, tokensFile } = resolveRepoPaths();
|
||||
const tokens = resolveForbiddenTokens(tokensFile);
|
||||
process.exit(runForbiddenTokenCheck({ repoRoot, tokens }));
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
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#^\./##'
|
||||
)
|
||||
@@ -37,6 +37,7 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
|
||||
70
server/src/__tests__/activity-routes.test.ts
Normal file
70
server/src/__tests__/activity-routes.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { activityRoutes } from "../routes/activity.js";
|
||||
|
||||
const mockActivityService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
forIssue: vi.fn(),
|
||||
runsForIssue: vi.fn(),
|
||||
issuesForRun: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getByIdentifier: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/activity.js", () => ({
|
||||
activityService: () => mockActivityService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
issueService: () => mockIssueService,
|
||||
}));
|
||||
|
||||
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", activityRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("activity routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves issue identifiers before loading runs", async () => {
|
||||
mockIssueService.getByIdentifier.mockResolvedValue({
|
||||
id: "issue-uuid-1",
|
||||
companyId: "company-1",
|
||||
});
|
||||
mockActivityService.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).get("/api/issues/PAP-475/runs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
|
||||
expect(res.body).toEqual([{ runId: "run-1" }]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
sessionCodec as cursorSessionCodec,
|
||||
isCursorUnknownSessionError,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import {
|
||||
sessionCodec as geminiSessionCodec,
|
||||
isGeminiUnknownSessionError,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import {
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
isOpenCodeUnknownSessionError,
|
||||
@@ -82,6 +86,24 @@ describe("adapter session codecs", () => {
|
||||
});
|
||||
expect(cursorSessionCodec.getDisplayId?.(serialized ?? null)).toBe("cursor-session-1");
|
||||
});
|
||||
|
||||
it("normalizes gemini session params with cwd", () => {
|
||||
const parsed = geminiSessionCodec.deserialize({
|
||||
session_id: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
|
||||
const serialized = geminiSessionCodec.serialize(parsed);
|
||||
expect(serialized).toEqual({
|
||||
sessionId: "gemini-session-1",
|
||||
cwd: "/tmp/gemini",
|
||||
});
|
||||
expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex resume recovery detection", () => {
|
||||
@@ -146,3 +168,26 @@ describe("cursor resume recovery detection", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini resume recovery detection", () => {
|
||||
it("detects unknown session errors from gemini output", () => {
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"unknown session id abc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"",
|
||||
"checkpoint latest not found",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGeminiUnknownSessionError(
|
||||
"{\"type\":\"result\",\"subtype\":\"success\"}",
|
||||
"",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: "item_2",
|
||||
input: { id: "item_2", command: "/bin/zsh -lc ls" },
|
||||
},
|
||||
]);
|
||||
@@ -106,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
|
||||
item: {
|
||||
id: "item_52",
|
||||
type: "file_change",
|
||||
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
@@ -116,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
|
||||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
|
||||
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "shellToolCall",
|
||||
toolUseId: "call_shell_1",
|
||||
input: { command: longCommand },
|
||||
},
|
||||
]);
|
||||
@@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => {
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]);
|
||||
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
|
||||
|
||||
expect(
|
||||
parseCursorStdoutLine(
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
77
server/src/__tests__/forbidden-tokens.test.ts
Normal file
77
server/src/__tests__/forbidden-tokens.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
resolveDynamicForbiddenTokens,
|
||||
resolveForbiddenTokens,
|
||||
runForbiddenTokenCheck,
|
||||
} = await import("../../../scripts/check-forbidden-tokens.mjs");
|
||||
|
||||
describe("forbidden token check", () => {
|
||||
it("derives username tokens without relying on whoami", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{ USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" },
|
||||
{
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "pc"]);
|
||||
});
|
||||
|
||||
it("falls back cleanly when user resolution fails", () => {
|
||||
const tokens = resolveDynamicForbiddenTokens(
|
||||
{},
|
||||
{
|
||||
userInfo: () => {
|
||||
throw new Error("missing user");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges dynamic and file-based forbidden tokens", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
|
||||
const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n");
|
||||
|
||||
try {
|
||||
const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, {
|
||||
userInfo: () => ({ username: "paperclip" }),
|
||||
});
|
||||
|
||||
expect(tokens).toEqual(["paperclip", "custom-token"]);
|
||||
} finally {
|
||||
fs.unlinkSync(tokensFile);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports matches without leaking which token was searched", () => {
|
||||
const exec = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce("server/file.ts:1:found\n")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
|
||||
const exitCode = runForbiddenTokenCheck({
|
||||
repoRoot: "/repo",
|
||||
tokens: ["paperclip", "custom-token"],
|
||||
exec,
|
||||
log,
|
||||
error,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(exec).toHaveBeenCalledTimes(2);
|
||||
expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n");
|
||||
expect(error).toHaveBeenCalledWith(" server/file.ts:1:found");
|
||||
expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { testEnvironment } from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(binDir: string, argsCapturePath: string): Promise<string> {
|
||||
const commandPath = path.join(binDir, "gemini");
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
|
||||
if (outPath) {
|
||||
fs.writeFileSync(outPath, JSON.stringify(process.argv.slice(2)), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "hello",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
return commandPath;
|
||||
}
|
||||
|
||||
describe("gemini_local environment diagnostics", () => {
|
||||
it("creates a missing working directory when cwd is absolute", async () => {
|
||||
const cwd = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
"workspace",
|
||||
);
|
||||
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: process.execPath,
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "gemini_cwd_valid")).toBe(true);
|
||||
expect(result.checks.some((check) => check.level === "error")).toBe(false);
|
||||
const stats = await fs.stat(cwd);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("passes model and yolo flags to the hello probe", async () => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-gemini-local-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const binDir = path.join(root, "bin");
|
||||
const cwd = path.join(root, "workspace");
|
||||
const argsCapturePath = path.join(root, "args.json");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await writeFakeGeminiCommand(binDir, argsCapturePath);
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
command: "gemini",
|
||||
cwd,
|
||||
model: "gemini-2.5-pro",
|
||||
yolo: true,
|
||||
env: {
|
||||
GEMINI_API_KEY: "test-key",
|
||||
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe("fail");
|
||||
const args = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as string[];
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("gemini-2.5-pro");
|
||||
expect(args).toContain("--approval-mode");
|
||||
expect(args).toContain("yolo");
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
189
server/src/__tests__/gemini-local-adapter.test.ts
Normal file
189
server/src/__tests__/gemini-local-adapter.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server";
|
||||
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
|
||||
describe("gemini_local parser", () => {
|
||||
it("extracts session, summary, usage, cost, and terminal error message", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "output_text", text: "hello" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: {
|
||||
promptTokenCount: 12,
|
||||
cachedContentTokenCount: 3,
|
||||
candidatesTokenCount: 7,
|
||||
},
|
||||
total_cost_usd: 0.00123,
|
||||
result: "done",
|
||||
}),
|
||||
JSON.stringify({ type: "error", message: "model access denied" }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("gemini-session-1");
|
||||
expect(parsed.summary).toBe("hello");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 12,
|
||||
cachedInputTokens: 3,
|
||||
outputTokens: 7,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
|
||||
it("extracts structured questions", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I have a question." },
|
||||
{
|
||||
type: "question",
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.summary).toBe("I have a question.");
|
||||
expect(parsed.question).toEqual({
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local stale session detection", () => {
|
||||
it("treats missing session messages as an unknown session error", () => {
|
||||
expect(isGeminiUnknownSessionError("", "unknown session id abc")).toBe(true);
|
||||
expect(isGeminiUnknownSessionError("", "checkpoint latest not found")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local ui stdout parser", () => {
|
||||
it("parses assistant, thinking, and result events", () => {
|
||||
const ts = "2026-03-08T00:00:00.000Z";
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I checked the repo." },
|
||||
{ type: "thinking", text: "Reviewing adapter registry" },
|
||||
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
|
||||
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "assistant", ts, text: "I checked the repo." },
|
||||
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
|
||||
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
|
||||
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
|
||||
]);
|
||||
|
||||
expect(
|
||||
parseGeminiStdoutLine(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Done",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
is_error: false,
|
||||
}),
|
||||
ts,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "Done",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 2,
|
||||
costUsd: 0.00042,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("gemini_local cli formatter", () => {
|
||||
it("prints init, assistant, result, and error events", () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
let joined = "";
|
||||
|
||||
try {
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
cachedContentTokenCount: 2,
|
||||
},
|
||||
total_cost_usd: 0.00042,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printGeminiStreamEvent(
|
||||
JSON.stringify({ type: "error", message: "boom" }),
|
||||
false,
|
||||
);
|
||||
joined = spy.mock.calls.map((call) => stripAnsi(call.join(" "))).join("\n");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
expect(joined).toContain("Gemini init");
|
||||
expect(joined).toContain("assistant: hello");
|
||||
expect(joined).toContain("tokens: in=10 out=5 cached=2 cost=$0.000420");
|
||||
expect(joined).toContain("error: boom");
|
||||
});
|
||||
});
|
||||
168
server/src/__tests__/gemini-local-execute.test.ts
Normal file
168
server/src/__tests__/gemini-local-execute.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execute } from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
paperclipEnvKeys: Object.keys(process.env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort(),
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "gemini-session-1",
|
||||
model: "gemini-2.5-pro",
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "hello" }] },
|
||||
}));
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "gemini-session-1",
|
||||
result: "ok",
|
||||
}));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
type CapturePayload = {
|
||||
argv: string[];
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
describe("gemini execute", () => {
|
||||
it("passes prompt as final argument and injects paperclip env vars", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
let invocationPrompt = "";
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Coder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "gemini-2.5-pro",
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
invocationPrompt = meta.prompt ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--output-format");
|
||||
expect(capture.argv).toContain("stream-json");
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
expect(capture.argv.at(-1)).toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.argv.at(-1)).toContain("Paperclip runtime note:");
|
||||
expect(capture.paperclipEnvKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
"PAPERCLIP_AGENT_ID",
|
||||
"PAPERCLIP_API_KEY",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_COMPANY_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
]),
|
||||
);
|
||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||
expect(invocationPrompt).toContain("run_shell_command");
|
||||
expect(result.question).toBeNull();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("always passes --approval-mode yolo", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-yolo",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
expect(capture.argv).not.toContain("--policy");
|
||||
expect(capture.argv).not.toContain("--allow-all");
|
||||
expect(capture.argv).not.toContain("--allow-read");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
33
server/src/__tests__/heartbeat-run-summary.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeHeartbeatRunResultJson } from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
it("truncates text fields and preserves cost aliases", () => {
|
||||
const summary = summarizeHeartbeatRunResultJson({
|
||||
summary: "a".repeat(600),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
nested: { ignored: true },
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
summary: "a".repeat(500),
|
||||
result: "ok",
|
||||
message: "done",
|
||||
error: "failed",
|
||||
total_cost_usd: 1.23,
|
||||
cost_usd: 0.45,
|
||||
costUsd: 0.67,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-object and irrelevant payloads", () => {
|
||||
expect(summarizeHeartbeatRunResultJson(null)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<string, unknown>)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
66
server/src/__tests__/log-redaction.test.ts
Normal file
66
server/src/__tests__/log-redaction.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CURRENT_USER_REDACTION_TOKEN,
|
||||
redactCurrentUserText,
|
||||
redactCurrentUserValue,
|
||||
} from "../log-redaction.js";
|
||||
|
||||
describe("log redaction", () => {
|
||||
it("redacts the active username inside home-directory paths", () => {
|
||||
const userName = "paperclipuser";
|
||||
const input = [
|
||||
`cwd=/Users/${userName}/paperclip`,
|
||||
`home=/home/${userName}/workspace`,
|
||||
`win=C:\\Users\\${userName}\\paperclip`,
|
||||
].join("\n");
|
||||
|
||||
const result = redactCurrentUserText(input, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
|
||||
});
|
||||
|
||||
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
|
||||
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
|
||||
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
|
||||
expect(result).not.toContain(userName);
|
||||
});
|
||||
|
||||
it("redacts standalone username mentions without mangling larger tokens", () => {
|
||||
const userName = "paperclipuser";
|
||||
const result = redactCurrentUserText(
|
||||
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
|
||||
{
|
||||
userNames: [userName],
|
||||
homeDirs: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
|
||||
);
|
||||
});
|
||||
|
||||
it("recursively redacts nested event payloads", () => {
|
||||
const userName = "paperclipuser";
|
||||
const result = redactCurrentUserValue({
|
||||
cwd: `/Users/${userName}/paperclip`,
|
||||
prompt: `open /Users/${userName}/paperclip/ui`,
|
||||
nested: {
|
||||
author: userName,
|
||||
},
|
||||
values: [userName, `/home/${userName}/project`],
|
||||
}, {
|
||||
userNames: [userName],
|
||||
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
|
||||
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
|
||||
nested: {
|
||||
author: CURRENT_USER_REDACTION_TOKEN,
|
||||
},
|
||||
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -441,6 +469,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 +555,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({
|
||||
|
||||
@@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "bash",
|
||||
toolUseId: "call_1",
|
||||
input: { command: "ls -1" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -52,5 +52,5 @@ describe("privateHostnameGuard", () => {
|
||||
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.text).toContain("please run pnpm paperclipai allowed-hostname dotta-macbook-pro");
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user