mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-26 01:35:18 +02:00
Compare commits
133 Commits
feature/wo
...
@paperclip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
964e04369a | ||
|
|
873535fbf0 | ||
|
|
87c0bf9cdf | ||
|
|
56df8d3cf0 | ||
|
|
8808a33fe1 | ||
|
|
ac82cae39a | ||
|
|
9c6a913ef1 | ||
|
|
18f7092b71 | ||
|
|
c8b08e64d6 | ||
|
|
e6ff4eb8b2 | ||
|
|
7adc14ab50 | ||
|
|
aeafeba12b | ||
|
|
890ff39bdb | ||
|
|
55c145bff2 | ||
|
|
7809405e8f | ||
|
|
88916fd11b | ||
|
|
06b50ba161 | ||
|
|
f76a7ef408 | ||
|
|
448e9c192b | ||
|
|
1d5e5247e8 | ||
|
|
5f3f354b3a | ||
|
|
7df74b170d | ||
|
|
7e6a5682fa | ||
|
|
e6a684d96a | ||
|
|
c3cf4279fa | ||
|
|
d4d1b2e7f9 | ||
|
|
b7744a2215 | ||
|
|
f5c766beb9 | ||
|
|
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 | ||
|
|
0c525febf2 | ||
|
|
b0fe48b730 | ||
|
|
f3a9b6de21 | ||
|
|
6956dad53a | ||
|
|
e9fc403b94 | ||
|
|
8eb8b16047 | ||
|
|
4e5f67ef96 | ||
|
|
ec445e4cc9 | ||
|
|
af97259a9c | ||
|
|
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.
|
||||
39
.github/workflows/pr-policy.yml
vendored
39
.github/workflows/pr-policy.yml
vendored
@@ -13,6 +13,8 @@ jobs:
|
||||
policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -31,19 +33,38 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Block manual lockfile edits
|
||||
if: github.head_ref != 'chore/refresh-lockfile'
|
||||
- name: Enforce lockfile policy when manifests change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
|
||||
manifest_changed=false
|
||||
lockfile_changed=false
|
||||
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
manifest_changed=true
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||
lockfile_changed=true
|
||||
fi
|
||||
|
||||
if [ "$lockfile_changed" = true ] && [ "$manifest_changed" != true ]; then
|
||||
echo "pnpm-lock.yaml changed without a dependency manifest change." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
if [ "$manifest_changed" = true ]; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
if ! git diff --quiet -- pnpm-lock.yaml; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "pnpm-lock.yaml is stale for this PR. Wait for the Refresh Lockfile workflow to push the bot commit, then rerun checks." >&2
|
||||
else
|
||||
echo "pnpm-lock.yaml is stale for this fork PR. Run pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile and commit pnpm-lock.yaml." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
2
.github/workflows/pr-verify.yml
vendored
2
.github/workflows/pr-verify.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
111
.github/workflows/refresh-lockfile-pr.yml
vendored
Normal file
111
.github/workflows/refresh-lockfile-pr.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Refresh Lockfile
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: refresh-lockfile-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Detect dependency manifest changes
|
||||
id: changes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "manifest_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "same_repo=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "same_repo=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout pull request head
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
run: |
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "Lockfile is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||
echo "Unexpected files changed during lockfile refresh:"
|
||||
echo "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit refreshed lockfile to same-repo PR branch
|
||||
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo == 'true'
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "lockfile-bot"
|
||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push origin "HEAD:${{ github.event.pull_request.head.ref }}"
|
||||
|
||||
- name: Fail fork PRs that need a lockfile refresh
|
||||
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo != 'true'
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "This fork PR changes dependency manifests and requires a refreshed pnpm-lock.yaml." >&2
|
||||
echo "Run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile" >&2
|
||||
echo "Then commit pnpm-lock.yaml to the PR branch." >&2
|
||||
exit 1
|
||||
81
.github/workflows/refresh-lockfile.yml
vendored
81
.github/workflows/refresh-lockfile.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: Refresh Lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: refresh-lockfile-master
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
run: |
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "Lockfile is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||
echo "Unexpected files changed during lockfile refresh:"
|
||||
echo "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or update pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="chore/refresh-lockfile"
|
||||
git config user.name "lockfile-bot"
|
||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||
|
||||
git checkout -B "$BRANCH"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push --force origin "$BRANCH"
|
||||
|
||||
# Create PR if one doesn't already exist
|
||||
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
|
||||
if [ -z "$existing" ]; then
|
||||
gh pr create \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
||||
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
|
||||
echo "Created new PR."
|
||||
else
|
||||
echo "PR #$existing already exists, branch updated via force push."
|
||||
fi
|
||||
@@ -16,6 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
|
||||
@@ -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, rebindWorkspaceCwd } 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 });
|
||||
}
|
||||
});
|
||||
@@ -172,6 +262,36 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -199,4 +319,87 @@ describe("worktree helpers", () => {
|
||||
}),
|
||||
).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,4 +1,16 @@
|
||||
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";
|
||||
@@ -50,6 +62,10 @@ type WorktreeInitOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||
startPoint?: string;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
@@ -80,6 +96,14 @@ type EmbeddedPostgresHandle = {
|
||||
type GitWorkspaceInfo = {
|
||||
root: string;
|
||||
commonDir: string;
|
||||
gitDir: string;
|
||||
hooksPath: string;
|
||||
};
|
||||
|
||||
type CopiedGitHooksResult = {
|
||||
sourceHooksPath: string;
|
||||
targetHooksPath: string;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
type SeedWorktreeDatabaseResult = {
|
||||
@@ -95,6 +119,72 @@ 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 {
|
||||
@@ -162,15 +252,88 @@ function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
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;
|
||||
@@ -285,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",
|
||||
@@ -303,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);
|
||||
|
||||
@@ -445,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,
|
||||
@@ -490,9 +651,20 @@ 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"] = [];
|
||||
@@ -527,6 +699,11 @@ 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}`));
|
||||
@@ -543,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);
|
||||
@@ -566,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`).
|
||||
|
||||
@@ -19,9 +19,9 @@ Current implementation status:
|
||||
|
||||
GitHub Actions owns `pnpm-lock.yaml`.
|
||||
|
||||
- Do not commit `pnpm-lock.yaml` in pull requests.
|
||||
- Pull request CI validates dependency resolution when manifests change.
|
||||
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
|
||||
- Same-repo pull requests that change dependency manifests are auto-refreshed by GitHub Actions before merge.
|
||||
- Fork pull requests that change dependency manifests must include the refreshed `pnpm-lock.yaml`.
|
||||
- Pull request CI validates lockfile freshness when manifests change and verifies with `--frozen-lockfile`.
|
||||
|
||||
## Start Dev
|
||||
|
||||
@@ -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,73 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Agent Chat UI and Issue-Backed Conversations
|
||||
|
||||
## Context
|
||||
|
||||
`PAP-475` asks two related questions:
|
||||
|
||||
1. What UI kit should Paperclip use if we add a chat surface with an agent?
|
||||
2. How should chat fit the product without breaking the current issue-centric model?
|
||||
|
||||
This is not only a component-library decision. In Paperclip today:
|
||||
|
||||
- V1 explicitly says communication is `tasks + comments only`, with no separate chat system.
|
||||
- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage.
|
||||
- Live run streaming already exists on issue detail pages.
|
||||
- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`.
|
||||
- The OpenClaw gateway adapter already supports an issue-scoped session key strategy.
|
||||
|
||||
That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have."
|
||||
|
||||
## Current Constraints From the Codebase
|
||||
|
||||
### Durable work object
|
||||
|
||||
The durable object in Paperclip is the issue, not a chat thread.
|
||||
|
||||
- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline.
|
||||
- `CommentThread` already renders markdown comments and supports reply/reassignment flows.
|
||||
- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs.
|
||||
|
||||
### Session behavior
|
||||
|
||||
Session continuity is already task-shaped.
|
||||
|
||||
- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`.
|
||||
- `agent_task_sessions` stores session state per company + agent + adapter + task key.
|
||||
- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well.
|
||||
|
||||
That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system.
|
||||
|
||||
### Billing behavior
|
||||
|
||||
Billing is already issue-aware.
|
||||
|
||||
- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`.
|
||||
- heartbeat context already propagates issue linkage into runs and cost rollups.
|
||||
|
||||
If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable.
|
||||
|
||||
## UI Kit Recommendation
|
||||
|
||||
## Recommendation: `assistant-ui`
|
||||
|
||||
Use `assistant-ui` as the chat presentation layer.
|
||||
|
||||
Why it fits Paperclip:
|
||||
|
||||
- It is a real chat UI kit, not just a hook.
|
||||
- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well.
|
||||
- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls.
|
||||
- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering.
|
||||
|
||||
Why not make "the Vercel one" the primary choice:
|
||||
|
||||
- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols.
|
||||
- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip.
|
||||
- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model.
|
||||
|
||||
So the clean split is:
|
||||
|
||||
- `assistant-ui` for UI primitives
|
||||
- Paperclip-owned runtime/store for state, persistence, and transport
|
||||
- optional AI SDK usage later only if we want its stream protocol or client transport abstraction
|
||||
|
||||
## Product Options
|
||||
|
||||
### Option A: Separate chat object
|
||||
|
||||
Create a new top-level chat/thread model unrelated to issues.
|
||||
|
||||
Pros:
|
||||
|
||||
- clean mental model if users want freeform conversation
|
||||
- easy to hide from issue boards
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current V1 product decision that communication is issue-centric
|
||||
- needs new persistence, billing, session, permissions, activity, and wakeup rules
|
||||
- creates a second "why does this exist?" object beside issues
|
||||
- makes "pick up an old chat" a separate retrieval problem
|
||||
|
||||
Verdict: not recommended for V1.
|
||||
|
||||
### Option B: Every chat is an issue
|
||||
|
||||
Treat chat as a UI mode over an issue. The issue remains the durable record.
|
||||
|
||||
Pros:
|
||||
|
||||
- matches current product spec
|
||||
- billing, runs, comments, approvals, and activity already work
|
||||
- sessions already resume on issue identity
|
||||
- works with all adapters, including OpenClaw, without new agent auth or a second API surface
|
||||
|
||||
Cons:
|
||||
|
||||
- some chats are not really "tasks" in a board sense
|
||||
- onboarding and review conversations may clutter normal issue lists
|
||||
|
||||
Verdict: best V1 foundation.
|
||||
|
||||
### Option C: Hybrid with hidden conversation issues
|
||||
|
||||
Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted.
|
||||
|
||||
Pros:
|
||||
|
||||
- preserves the issue-centric backend
|
||||
- gives onboarding/review chat a cleaner UX
|
||||
- preserves billing and session continuity
|
||||
|
||||
Cons:
|
||||
|
||||
- requires extra UI rules and possibly a small schema or filtering addition
|
||||
- can become a disguised second system if not kept narrow
|
||||
|
||||
Verdict: likely the right product shape after a basic issue-backed MVP.
|
||||
|
||||
## Recommended Product Model
|
||||
|
||||
### Phase 1 product decision
|
||||
|
||||
For the first implementation, chat should be issue-backed.
|
||||
|
||||
More specifically:
|
||||
|
||||
- the board opens a chat surface for an issue
|
||||
- sending a message is a comment mutation on that issue
|
||||
- the assigned agent is woken through the existing issue-comment flow
|
||||
- streaming output comes from the existing live run stream for that issue
|
||||
- durable assistant output remains comments and run history, not an extra transcript store
|
||||
|
||||
This keeps Paperclip honest about what it is:
|
||||
|
||||
- the control plane stays issue-centric
|
||||
- chat is a better way to interact with issue work, not a new collaboration product
|
||||
|
||||
### Onboarding and CEO conversations
|
||||
|
||||
For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
- create a board-initiated issue assigned to the CEO
|
||||
- mark it as conversation-flavored in UI treatment
|
||||
- optionally hide it from normal issue boards by default later
|
||||
- keep all cost/run/session linkage on that issue
|
||||
|
||||
This solves several concerns at once:
|
||||
|
||||
- no separate API key or direct provider wiring is needed
|
||||
- the same CEO adapter is used
|
||||
- old conversations are recovered through normal issue history
|
||||
- the CEO can still create or update real child issues from the conversation
|
||||
|
||||
## Session Model
|
||||
|
||||
### V1
|
||||
|
||||
Use one durable conversation session per issue.
|
||||
|
||||
That already matches current behavior:
|
||||
|
||||
- adapter task sessions persist against `taskKey`
|
||||
- `taskKey` already falls back to `issueId`
|
||||
- OpenClaw already supports an issue-scoped session key
|
||||
|
||||
This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue.
|
||||
|
||||
### What not to add yet
|
||||
|
||||
Do not add multi-thread-per-issue chat in the first pass.
|
||||
|
||||
If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive:
|
||||
|
||||
- `taskKey = issue:<issueId>:conversation:<conversationId>`
|
||||
- OpenClaw `sessionKey = paperclip:conversation:<conversationId>`
|
||||
|
||||
Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule.
|
||||
|
||||
## Billing Model
|
||||
|
||||
Chat should not invent a separate billing pipeline.
|
||||
|
||||
All chat cost should continue to roll up through the issue:
|
||||
|
||||
- `cost_events.issueId`
|
||||
- project and goal rollups through existing relationships
|
||||
- issue `billingCode` when present
|
||||
|
||||
If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail.
|
||||
|
||||
This is another reason ephemeral freeform chat should not be the default.
|
||||
|
||||
## UI Architecture
|
||||
|
||||
### Recommended stack
|
||||
|
||||
1. Keep Paperclip as the source of truth for message history and run state.
|
||||
2. Add `assistant-ui` as the rendering/composer layer.
|
||||
3. Build a Paperclip runtime adapter that maps:
|
||||
- issue comments -> user/assistant messages
|
||||
- live run deltas -> streaming assistant messages
|
||||
- issue attachments -> chat attachments
|
||||
4. Keep current markdown rendering and code-block support where possible.
|
||||
|
||||
### Interaction flow
|
||||
|
||||
1. Board opens issue detail in "Chat" mode.
|
||||
2. Existing comment history is mapped into chat messages.
|
||||
3. When the board sends a message:
|
||||
- `POST /api/issues/{id}/comments`
|
||||
- optionally interrupt the active run if the UX wants "send and replace current response"
|
||||
4. Existing issue comment wakeup logic wakes the assignee.
|
||||
5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming.
|
||||
6. When the run completes, durable state remains in comments/runs/activity as it does now.
|
||||
|
||||
### Why this fits the current code
|
||||
|
||||
Paperclip already has most of the backend pieces:
|
||||
|
||||
- issue comments
|
||||
- run timeline
|
||||
- run log and event streaming
|
||||
- markdown rendering
|
||||
- attachment support
|
||||
- assignee wakeups on comments
|
||||
|
||||
The missing piece is mostly the presentation and the mapping layer, not a new backend domain.
|
||||
|
||||
## Agent Scope
|
||||
|
||||
Do not launch this as "chat with every agent."
|
||||
|
||||
Start narrower:
|
||||
|
||||
- onboarding chat with CEO
|
||||
- workflow/review chat with CEO
|
||||
- maybe selected exec roles later
|
||||
|
||||
Reasons:
|
||||
|
||||
- it keeps the feature from becoming a second inbox/chat product
|
||||
- it limits permission and UX questions early
|
||||
- it matches the stated product demand
|
||||
|
||||
If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly.
|
||||
|
||||
## Recommended Delivery Phases
|
||||
|
||||
### Phase 1: Chat UI on existing issues
|
||||
|
||||
- add a chat presentation mode to issue detail
|
||||
- use `assistant-ui`
|
||||
- map comments + live runs into the chat surface
|
||||
- no schema change
|
||||
- no new API surface
|
||||
|
||||
This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion.
|
||||
|
||||
### Phase 2: Conversation-flavored issues for CEO chat
|
||||
|
||||
- add a lightweight conversation classification
|
||||
- support creation of CEO conversation issues from onboarding and workflow entry points
|
||||
- optionally hide these from normal backlog/board views by default
|
||||
|
||||
The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later.
|
||||
|
||||
### Phase 3: Promotion and thread splitting only if needed
|
||||
|
||||
Only if we later see a real need:
|
||||
|
||||
- allow promoting a conversation to a formal task issue
|
||||
- allow several threads per issue with explicit conversation identity
|
||||
|
||||
This should be demand-driven, not designed up front.
|
||||
|
||||
## Clear Recommendation
|
||||
|
||||
If the question is "what should we use?", the answer is:
|
||||
|
||||
- use `assistant-ui` for the chat UI
|
||||
- do not treat raw Vercel AI SDK UI hooks as the main product answer
|
||||
- keep chat issue-backed in V1
|
||||
- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem
|
||||
|
||||
If the question is "how should we think about chat in Paperclip?", the answer is:
|
||||
|
||||
- chat is a mode of interacting with issue-backed agent work
|
||||
- not a separate product silo
|
||||
- not an excuse to stop tracing work, cost, and session history back to the issue
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Immediate implementation target
|
||||
|
||||
The most defensible first build is:
|
||||
|
||||
- add a chat tab or chat-focused layout on issue detail
|
||||
- back it with the currently assigned agent on that issue
|
||||
- use `assistant-ui` primitives over existing comments and live run events
|
||||
|
||||
### Defer these until proven necessary
|
||||
|
||||
- standalone global chat objects
|
||||
- multi-thread chat inside one issue
|
||||
- chat with every agent in the org
|
||||
- a second persistence layer for message history
|
||||
- separate cost tracking for chats
|
||||
|
||||
## References
|
||||
|
||||
- V1 communication model: `doc/SPEC-implementation.md`
|
||||
- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx`
|
||||
- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts`
|
||||
- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md`
|
||||
- assistant-ui docs: <https://www.assistant-ui.com/docs>
|
||||
- assistant-ui repo: <https://github.com/assistant-ui/assistant-ui>
|
||||
- AI SDK transport docs: <https://ai-sdk.dev/docs/ai-sdk-ui/transport>
|
||||
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
@@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
|
||||
"cwd": "/absolute/or/relative/path",
|
||||
"promptTemplate": "You are agent {{agent.id}} ...",
|
||||
"model": "optional-model-id",
|
||||
"maxTurnsPerRun": 80,
|
||||
"maxTurnsPerRun": 300,
|
||||
"dangerouslySkipPermissions": true,
|
||||
"env": {"KEY": "VALUE"},
|
||||
"extraArgs": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
|
||||
|
||||
## Prompt Templates
|
||||
|
||||
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",
|
||||
|
||||
@@ -22,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;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
@@ -189,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[] }
|
||||
|
||||
@@ -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 ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1069,7 +1069,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const agentParams: Record<string, unknown> = {
|
||||
...payloadTemplate,
|
||||
paperclip: paperclipPayload,
|
||||
message,
|
||||
sessionKey,
|
||||
idempotencyKey: ctx.runId,
|
||||
|
||||
@@ -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();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
|
||||
@@ -99,6 +99,7 @@ export type {
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
LiveEvent,
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type {
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
} from "./heartbeat.js";
|
||||
export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.2
|
||||
version: 1.58.2
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
esbuild:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
@@ -38,6 +41,9 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-gemini-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/gemini-local
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
@@ -68,6 +74,9 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: 0.38.4
|
||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||
embedded-postgres:
|
||||
specifier: ^18.1.0-beta.16
|
||||
version: 18.1.0-beta.16
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -139,6 +148,22 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapters/gemini-local:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../adapter-utils
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapters/openclaw-gateway:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils':
|
||||
@@ -245,6 +270,9 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-gemini-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/gemini-local
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
@@ -321,6 +349,9 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
supertest:
|
||||
specifier: ^7.0.0
|
||||
version: 7.2.2
|
||||
@@ -360,6 +391,9 @@ importers:
|
||||
'@paperclipai/adapter-cursor-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/cursor-local
|
||||
'@paperclipai/adapter-gemini-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/gemini-local
|
||||
'@paperclipai/adapter-openclaw-gateway':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/openclaw-gateway
|
||||
@@ -989,6 +1023,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@epic-web/invariant@1.0.0':
|
||||
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
@@ -3424,6 +3461,11 @@ packages:
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-env@10.1.0:
|
||||
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6741,6 +6783,8 @@ snapshots:
|
||||
'@embedded-postgres/windows-x64@18.1.0-beta.16':
|
||||
optional: true
|
||||
|
||||
'@epic-web/invariant@1.0.0': {}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
dependencies:
|
||||
esbuild: 0.18.20
|
||||
@@ -9255,6 +9299,11 @@ snapshots:
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-env@10.1.0:
|
||||
dependencies:
|
||||
'@epic-web/invariant': 1.0.0
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
||||
54
releases/v0.3.1.md
Normal file
54
releases/v0.3.1.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# v0.3.1
|
||||
|
||||
> Released: 2026-03-12
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Gemini CLI adapter** — Full local adapter support for Google's Gemini CLI. Includes API-key detection, turn-limit handling, sandbox and approval modes, skill injection into `~/.gemini/`, and yolo-mode default. ([#452](https://github.com/paperclipai/paperclip/pull/452), [#656](https://github.com/paperclipai/paperclip/pull/656), @aaaaron)
|
||||
- **Run transcript polish** — Run transcripts render markdown, fold command stdout, redact home paths and user identities, and display humanized event labels across both detail and live surfaces. ([#648](https://github.com/paperclipai/paperclip/pull/648), [#695](https://github.com/paperclipai/paperclip/pull/695))
|
||||
- **Inbox refinements** — Improved tab behavior, badge counts aligned with visible unread items, better mobile layout, and smoother new-issue submit state. ([#613](https://github.com/paperclipai/paperclip/pull/613))
|
||||
- **Improved onboarding wizard** — Onboarding now shows Claude Code and Codex as recommended adapters, collapses other types, and features animated step transitions with clickable tabs. Adapter environment checks animate on success and show debug output only on failure. ([#700](https://github.com/paperclipai/paperclip/pull/700))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Instance heartbeat settings sidebar** — View and manage heartbeat configuration directly from the instance settings page with compact grouped run lists. ([#697](https://github.com/paperclipai/paperclip/pull/697))
|
||||
- **Project and agent configuration tabs** — New tabbed configuration UI for projects and agents, including execution workspace policy settings. ([#613](https://github.com/paperclipai/paperclip/pull/613))
|
||||
- **Agent runs tab** — Agent detail pages now include a dedicated runs tab.
|
||||
- **Configurable attachment content types** — The `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` env var lets operators control which file types can be uploaded. ([#495](https://github.com/paperclipai/paperclip/pull/495), @subhendukundu)
|
||||
- **Default max turns raised to 300** — Agents now default to 300 max turns instead of the previous limit. ([#701](https://github.com/paperclipai/paperclip/pull/701))
|
||||
- **Issue creator shown in sidebar** — The issue properties pane now displays who created each issue. ([#145](https://github.com/paperclipai/paperclip/pull/145), @cschneid)
|
||||
- **Company-aware 404 handling** — The UI now shows company-scoped not-found pages instead of a generic error.
|
||||
- **Tools for Worktree workflow for developers** — New `paperclipai worktree:make` command provisions isolated development instances with their own database, secrets, favicon branding, and git hooks. Worktrees support minimal seed mode, start-point selection, and automatic workspace rebinding. ([#496](https://github.com/paperclipai/paperclip/pull/496), [#530](https://github.com/paperclipai/paperclip/pull/530), [#545](https://github.com/paperclipai/paperclip/pull/545))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Gemini Docker build** — Include the Gemini adapter manifest in the Docker deps stage so production builds succeed. ([#706](https://github.com/paperclipai/paperclip/pull/706), @zvictor)
|
||||
- **Approval retries made idempotent** — Duplicate approval submissions no longer create duplicate records. ([#502](https://github.com/paperclipai/paperclip/pull/502), @davidahmann)
|
||||
- **Heartbeat cost recording** — Costs are now routed through `costService` instead of being recorded inline, fixing missing cost attribution. ([#386](https://github.com/paperclipai/paperclip/pull/386), @domocarroll)
|
||||
- **Claude Code env var leak** — Child adapter processes no longer inherit Claude Code's internal environment variables. ([#485](https://github.com/paperclipai/paperclip/pull/485), @jknair)
|
||||
- **`parentId` query filter** — The issues list endpoint now correctly applies the `parentId` filter. ([#491](https://github.com/paperclipai/paperclip/pull/491), @lazmo88)
|
||||
- **Remove `Cmd+1..9` shortcut** — The company-switch keyboard shortcut conflicted with browser tab switching and has been removed. ([#628](https://github.com/paperclipai/paperclip/pull/628), @STRML)
|
||||
- **IME composition Enter** — Pressing Enter during IME composition in the new-issue title no longer moves focus prematurely. ([#578](https://github.com/paperclipai/paperclip/pull/578), @kaonash)
|
||||
- **Restart hint after hostname change** — The CLI now reminds users to restart the server after changing allowed hostnames. ([#549](https://github.com/paperclipai/paperclip/pull/549), @mvanhorn)
|
||||
- **Default `dangerouslySkipPermissions` for unattended agents** — Agents running without a terminal now default to skipping permission prompts instead of hanging. ([#388](https://github.com/paperclipai/paperclip/pull/388), @ohld)
|
||||
- **Remove stale `paperclip` property from OpenClaw Gateway** — Cleaned up an invalid agent parameter that caused warnings. ([#626](https://github.com/paperclipai/paperclip/pull/626), @openagen)
|
||||
- **Issue description overflow** — Long descriptions no longer break the layout.
|
||||
- **Worktree JWT persistence** — Environment-sensitive JWT config is now correctly carried into worktree instances.
|
||||
- **Dev migration prompt** — Fixed embedded `db:migrate` flow for local development.
|
||||
- **Markdown link dialog positioning** — The link insertion dialog no longer renders off-screen.
|
||||
- **Pretty logger metadata** — Server log metadata stays on one line instead of wrapping.
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Two new database migrations (`0026`, `0027`) will run automatically on startup:
|
||||
|
||||
- **Migration 0026** adds the `workspace_runtime_services` table for worktree-aware runtime support.
|
||||
- **Migration 0027** adds `execution_workspace_settings` to issues and `execution_workspace_policy` to projects.
|
||||
|
||||
Both are additive (new table and new columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@aaaaron, @adamrobbie-nudge, @cschneid, @davidahmann, @domocarroll, @jknair, @kaonash, @lazmo88, @mvanhorn, @ohld, @openagen, @STRML, @subhendukundu, @zvictor
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -196,6 +196,36 @@ npm_version_exists() {
|
||||
[ "$resolved" = "$version" ]
|
||||
}
|
||||
|
||||
npm_package_version_exists() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local resolved
|
||||
|
||||
resolved="$(npm view "${package_name}@${version}" version 2>/dev/null || true)"
|
||||
[ "$resolved" = "$version" ]
|
||||
}
|
||||
|
||||
wait_for_npm_package_version() {
|
||||
local package_name="$1"
|
||||
local version="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-5}"
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
if npm_package_version_exists "$package_name" "$version"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
require_clean_worktree() {
|
||||
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
|
||||
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
|
||||
|
||||
@@ -181,10 +181,12 @@ for (const rel of roots) {
|
||||
rows.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [dir, name] of rows) {
|
||||
const key = `${dir}\t${name}`;
|
||||
const pkgPath = path.join(root, dir, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
const key = `${dir}\t${name}\t${pkg.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
process.stdout.write(`${dir}\t${name}\n`);
|
||||
process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
@@ -356,6 +358,8 @@ if [ "$canary" = true ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
|
||||
|
||||
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
|
||||
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
|
||||
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
@@ -403,6 +407,31 @@ else
|
||||
npx changeset publish
|
||||
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Post-publish verification: Confirming npm package availability..."
|
||||
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
|
||||
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
|
||||
MISSING_PUBLISHED_PACKAGES=""
|
||||
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
|
||||
[ -z "$pkg_name" ] && continue
|
||||
release_info " Checking $pkg_name@$pkg_version"
|
||||
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
|
||||
release_info " ✓ Found on npm"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
|
||||
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, "
|
||||
fi
|
||||
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
|
||||
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
|
||||
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
|
||||
fi
|
||||
|
||||
release_info " ✓ Verified all versioned packages are available on npm"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalService } from "../services/approvals.js";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
activatePendingApproval: vi.fn(),
|
||||
@@ -38,15 +38,12 @@ function createApproval(status: string): ApprovalRecord {
|
||||
}
|
||||
|
||||
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
|
||||
const selectWhere = vi.fn();
|
||||
for (const result of selectResults) {
|
||||
selectWhere.mockResolvedValueOnce(result);
|
||||
}
|
||||
|
||||
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().mockResolvedValue(updateResults);
|
||||
const returning = vi.fn(async () => updateResults);
|
||||
const updateWhere = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where: updateWhere }));
|
||||
const update = vi.fn(() => ({ set }));
|
||||
|
||||
@@ -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(
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
59
server/src/__tests__/issue-goal-fallback.test.ts
Normal file
59
server/src/__tests__/issue-goal-fallback.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIssueGoalId,
|
||||
resolveNextIssueGoalId,
|
||||
} from "../services/issue-goal-fallback.ts";
|
||||
|
||||
describe("issue goal fallback", () => {
|
||||
it("assigns the company goal when creating an issue without project or goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("keeps an explicit goal when creating an issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("does not force a company goal when the issue belongs to a project", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("backfills the company goal on update for legacy no-project issues", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("clears the fallback when a project is added later", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: "goal-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).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`],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -456,33 +456,6 @@ describe("openclaw gateway adapter execute", () => {
|
||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||
expect(payload?.paperclip).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-123",
|
||||
companyId: "company-123",
|
||||
agentId: "agent-123",
|
||||
taskId: "task-123",
|
||||
issueId: "issue-123",
|
||||
workspace: expect.objectContaining({
|
||||
cwd: "/tmp/worktrees/pap-123",
|
||||
strategy: "git_worktree",
|
||||
}),
|
||||
workspaces: [
|
||||
expect.objectContaining({
|
||||
id: "workspace-1",
|
||||
cwd: "/tmp/project",
|
||||
}),
|
||||
],
|
||||
workspaceRuntime: expect.objectContaining({
|
||||
services: [
|
||||
expect.objectContaining({
|
||||
name: "preview",
|
||||
lifecycle: "ephemeral",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||
} finally {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
sessionCodec as cursorSessionCodec,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as geminiExecute,
|
||||
testEnvironment as geminiTestEnvironment,
|
||||
sessionCodec as geminiSessionCodec,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
|
||||
import {
|
||||
execute as openCodeExecute,
|
||||
testEnvironment as openCodeTestEnvironment,
|
||||
@@ -80,6 +86,16 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const geminiLocalAdapter: ServerAdapterModule = {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
models: geminiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
execute: openclawGatewayExecute,
|
||||
@@ -118,6 +134,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
openCodeLocalAdapter,
|
||||
piLocalAdapter,
|
||||
cursorLocalAdapter,
|
||||
geminiLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
@@ -135,7 +136,7 @@ export async function createApp(
|
||||
];
|
||||
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
||||
if (uiDist) {
|
||||
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
|
||||
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
|
||||
app.use(express.static(uiDist));
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.status(200).set("Content-Type", "text/html").end(indexHtml);
|
||||
@@ -168,7 +169,7 @@ export async function createApp(
|
||||
try {
|
||||
const templatePath = path.resolve(uiRoot, "index.html");
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
const html = await vite.transformIndexHtml(req.originalUrl, template);
|
||||
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(html);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
138
server/src/log-redaction.ts
Normal file
138
server/src/log-redaction.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import os from "node:os";
|
||||
|
||||
export const CURRENT_USER_REDACTION_TOKEN = "[]";
|
||||
|
||||
interface CurrentUserRedactionOptions {
|
||||
replacement?: string;
|
||||
userNames?: string[];
|
||||
homeDirs?: string[];
|
||||
}
|
||||
|
||||
type CurrentUserCandidates = {
|
||||
userNames: string[];
|
||||
homeDirs: string[];
|
||||
replacement: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string) {
|
||||
return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function replaceLastPathSegment(pathValue: string, replacement: string) {
|
||||
const normalized = pathValue.replace(/[\\/]+$/, "");
|
||||
const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||
if (lastSeparator < 0) return replacement;
|
||||
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
|
||||
}
|
||||
|
||||
function defaultUserNames() {
|
||||
const candidates = [
|
||||
process.env.USER,
|
||||
process.env.LOGNAME,
|
||||
process.env.USERNAME,
|
||||
];
|
||||
|
||||
try {
|
||||
candidates.push(os.userInfo().username);
|
||||
} catch {
|
||||
// Some environments do not expose userInfo; env vars are enough fallback.
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
function defaultHomeDirs(userNames: string[]) {
|
||||
const candidates: Array<string | null | undefined> = [
|
||||
process.env.HOME,
|
||||
process.env.USERPROFILE,
|
||||
];
|
||||
|
||||
try {
|
||||
candidates.push(os.homedir());
|
||||
} catch {
|
||||
// Ignore and fall back to env hints below.
|
||||
}
|
||||
|
||||
for (const userName of userNames) {
|
||||
candidates.push(`/Users/${userName}`);
|
||||
candidates.push(`/home/${userName}`);
|
||||
candidates.push(`C:\\Users\\${userName}`);
|
||||
}
|
||||
|
||||
return uniqueNonEmpty(candidates);
|
||||
}
|
||||
|
||||
let cachedCurrentUserCandidates: CurrentUserCandidates | null = null;
|
||||
|
||||
function getDefaultCurrentUserCandidates(): CurrentUserCandidates {
|
||||
if (cachedCurrentUserCandidates) return cachedCurrentUserCandidates;
|
||||
const userNames = defaultUserNames();
|
||||
cachedCurrentUserCandidates = {
|
||||
userNames,
|
||||
homeDirs: defaultHomeDirs(userNames),
|
||||
replacement: CURRENT_USER_REDACTION_TOKEN,
|
||||
};
|
||||
return cachedCurrentUserCandidates;
|
||||
}
|
||||
|
||||
function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
|
||||
const defaults = getDefaultCurrentUserCandidates();
|
||||
const userNames = uniqueNonEmpty(opts?.userNames ?? defaults.userNames);
|
||||
const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaults.homeDirs);
|
||||
const replacement = opts?.replacement?.trim() || defaults.replacement;
|
||||
return { userNames, homeDirs, replacement };
|
||||
}
|
||||
|
||||
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
|
||||
if (!input) return input;
|
||||
|
||||
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
|
||||
let result = input;
|
||||
|
||||
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
|
||||
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
|
||||
const replacementDir = userNames.includes(lastSegment)
|
||||
? replaceLastPathSegment(homeDir, replacement)
|
||||
: replacement;
|
||||
result = result.split(homeDir).join(replacementDir);
|
||||
}
|
||||
|
||||
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
|
||||
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactCurrentUserValue<T>(value: T, opts?: CurrentUserRedactionOptions): T {
|
||||
if (typeof value === "string") {
|
||||
return redactCurrentUserText(value, opts) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactCurrentUserValue(entry, opts)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactCurrentUserValue(entry, opts);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
|
||||
const sharedOpts = {
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
singleLine: true,
|
||||
};
|
||||
|
||||
export const logger = pino({
|
||||
|
||||
@@ -22,6 +22,13 @@ export function activityRoutes(db: Db) {
|
||||
const svc = activityService(db);
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issueSvc.getByIdentifier(rawId);
|
||||
}
|
||||
return issueSvc.getById(rawId);
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/activity", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -47,42 +54,27 @@ export function activityRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs
|
||||
router.param("id", async (req, res, next, rawId) => {
|
||||
try {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await issueSvc.getByIdentifier(rawId);
|
||||
if (issue) {
|
||||
req.params.id = issue.id;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/issues/:id/activity", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
const rawId = req.params.id as string;
|
||||
const issue = await resolveIssueByRef(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.forIssue(id);
|
||||
const result = await svc.forIssue(issue.id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/runs", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
const rawId = req.params.id as string;
|
||||
const issue = await resolveIssueByRef(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.runsForIssue(issue.companyId, id);
|
||||
const result = await svc.runsForIssue(issue.companyId, issue.id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
deriveAgentUrlKey,
|
||||
isUuidLike,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
wakeAgentSchema,
|
||||
@@ -31,18 +33,21 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
claude_local: "instructionsFilePath",
|
||||
codex_local: "instructionsFilePath",
|
||||
gemini_local: "instructionsFilePath",
|
||||
opencode_local: "instructionsFilePath",
|
||||
cursor: "instructionsFilePath",
|
||||
};
|
||||
@@ -199,6 +204,21 @@ export function agentRoutes(db: Db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
||||
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
||||
return {
|
||||
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
|
||||
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
@@ -232,6 +252,10 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
@@ -447,6 +471,81 @@ export function agentRoutes(db: Db) {
|
||||
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
|
||||
});
|
||||
|
||||
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const accessConditions = [];
|
||||
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
|
||||
const allowedCompanyIds = req.actor.companyIds ?? [];
|
||||
if (allowedCompanyIds.length === 0) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: agentsTable.id,
|
||||
companyId: agentsTable.companyId,
|
||||
agentName: agentsTable.name,
|
||||
role: agentsTable.role,
|
||||
title: agentsTable.title,
|
||||
status: agentsTable.status,
|
||||
adapterType: agentsTable.adapterType,
|
||||
runtimeConfig: agentsTable.runtimeConfig,
|
||||
lastHeartbeatAt: agentsTable.lastHeartbeatAt,
|
||||
companyName: companies.name,
|
||||
companyIssuePrefix: companies.issuePrefix,
|
||||
})
|
||||
.from(agentsTable)
|
||||
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
||||
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
|
||||
.orderBy(companies.name, agentsTable.name);
|
||||
|
||||
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
||||
.map((row) => {
|
||||
const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig);
|
||||
const statusEligible =
|
||||
row.status !== "paused" &&
|
||||
row.status !== "terminated" &&
|
||||
row.status !== "pending_approval";
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
companyName: row.companyName,
|
||||
companyIssuePrefix: row.companyIssuePrefix,
|
||||
agentName: row.agentName,
|
||||
agentUrlKey: deriveAgentUrlKey(row.agentName, row.id),
|
||||
role: row.role as InstanceSchedulerHeartbeatAgent["role"],
|
||||
title: row.title,
|
||||
status: row.status as InstanceSchedulerHeartbeatAgent["status"],
|
||||
adapterType: row.adapterType,
|
||||
intervalSec: policy.intervalSec,
|
||||
heartbeatEnabled: policy.enabled,
|
||||
schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0,
|
||||
lastHeartbeatAt: row.lastHeartbeatAt,
|
||||
};
|
||||
})
|
||||
.filter((item) =>
|
||||
item.intervalSec > 0 &&
|
||||
item.status !== "paused" &&
|
||||
item.status !== "terminated" &&
|
||||
item.status !== "pending_approval",
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.schedulerActive !== right.schedulerActive) {
|
||||
return left.schedulerActive ? -1 : 1;
|
||||
}
|
||||
const companyOrder = left.companyName.localeCompare(right.companyName);
|
||||
if (companyOrder !== 0) return companyOrder;
|
||||
return left.agentName.localeCompare(right.agentName);
|
||||
});
|
||||
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/org", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -1346,6 +1445,17 @@ export function agentRoutes(db: Db) {
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Heartbeat run not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, run.companyId);
|
||||
res.json(redactCurrentUserValue(run));
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const runId = req.params.runId as string;
|
||||
@@ -1378,10 +1488,12 @@ export function agentRoutes(db: Db) {
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
||||
const redactedEvents = events.map((event) => ({
|
||||
...event,
|
||||
payload: redactEventPayload(event.payload),
|
||||
}));
|
||||
const redactedEvents = events.map((event) =>
|
||||
redactCurrentUserValue({
|
||||
...event,
|
||||
payload: redactEventPayload(event.payload),
|
||||
}),
|
||||
);
|
||||
res.json(redactedEvents);
|
||||
});
|
||||
|
||||
@@ -1478,7 +1590,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
|
||||
res.json({
|
||||
...run,
|
||||
...redactCurrentUserValue(run),
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
|
||||
@@ -294,13 +294,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.findMentionedProjectIds(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
||||
res.json({
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
joinRequests: joinRequestCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const hasFailedRuns = badges.failedRuns > 0;
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog } from "@paperclipai/db";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
|
||||
export interface LogActivityInput {
|
||||
@@ -17,6 +18,7 @@ export interface LogActivityInput {
|
||||
|
||||
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
||||
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
|
||||
await db.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
actorType: input.actorType,
|
||||
@@ -26,7 +28,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
entityId: input.entityId,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
details: sanitizedDetails,
|
||||
details: redactedDetails,
|
||||
});
|
||||
|
||||
publishLiveEvent({
|
||||
@@ -40,7 +42,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
entityId: input.entityId,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
details: sanitizedDetails,
|
||||
details: redactedDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
|
||||
function redactApprovalComment<T extends { body: string }>(comment: T): T {
|
||||
return {
|
||||
...comment,
|
||||
body: redactCurrentUserText(comment.body),
|
||||
};
|
||||
}
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
@@ -215,7 +223,8 @@ export function approvalService(db: Db) {
|
||||
eq(approvalComments.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(approvalComments.createdAt));
|
||||
.orderBy(asc(approvalComments.createdAt))
|
||||
.then((comments) => comments.map(redactApprovalComment));
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
@@ -224,6 +233,7 @@ export function approvalService(db: Db) {
|
||||
actor: { agentId?: string; userId?: string },
|
||||
) => {
|
||||
const existing = await getExistingApproval(approvalId);
|
||||
const redactedBody = redactCurrentUserText(body);
|
||||
return db
|
||||
.insert(approvalComments)
|
||||
.values({
|
||||
@@ -231,10 +241,10 @@ export function approvalService(db: Db) {
|
||||
approvalId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
body,
|
||||
body: redactedBody,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
.then((rows) => redactApprovalComment(rows[0]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
],
|
||||
gemini_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
],
|
||||
opencode_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
@@ -81,7 +85,7 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
||||
claude_local: [
|
||||
{ path: ["timeoutSec"], value: 0 },
|
||||
{ path: ["graceSec"], value: 15 },
|
||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||
{ path: ["maxTurnsPerRun"], value: 300 },
|
||||
],
|
||||
openclaw_gateway: [
|
||||
{ path: ["timeoutSec"], value: 120 },
|
||||
|
||||
@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
|
||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const staleTasks = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.status, "in_progress"),
|
||||
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const agentCounts: Record<string, number> = {
|
||||
active: 0,
|
||||
running: 0,
|
||||
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
pendingApprovals,
|
||||
staleTasks,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,47 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { goals } from "@paperclipai/db";
|
||||
|
||||
type GoalReader = Pick<Db, "select">;
|
||||
|
||||
export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) {
|
||||
const activeRootGoal = await db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(
|
||||
and(
|
||||
eq(goals.companyId, companyId),
|
||||
eq(goals.level, "company"),
|
||||
eq(goals.status, "active"),
|
||||
isNull(goals.parentId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (activeRootGoal) return activeRootGoal;
|
||||
|
||||
const anyRootGoal = await db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(
|
||||
and(
|
||||
eq(goals.companyId, companyId),
|
||||
eq(goals.level, "company"),
|
||||
isNull(goals.parentId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (anyRootGoal) return anyRootGoal;
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(and(eq(goals.companyId, companyId), eq(goals.level, "company")))
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
export function goalService(db: Db) {
|
||||
return {
|
||||
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
|
||||
@@ -13,6 +53,8 @@ export function goalService(db: Db) {
|
||||
.where(eq(goals.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
|
||||
|
||||
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(goals)
|
||||
|
||||
35
server/src/services/heartbeat-run-summary.ts
Normal file
35
server/src/services/heartbeat-run-summary.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
function truncateSummaryText(value: unknown, maxLength = 500) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? record[key] ?? null : undefined;
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary: Record<string, unknown> = {};
|
||||
const textFields = ["summary", "result", "message", "error"] as const;
|
||||
for (const key of textFields) {
|
||||
const value = truncateSummaryText(resultJson[key]);
|
||||
if (value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const numericFieldAliases = ["total_cost_usd", "cost_usd", "costUsd"] as const;
|
||||
for (const key of numericFieldAliases) {
|
||||
const value = readNumericField(resultJson, key);
|
||||
if (value !== undefined && value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
agentWakeupRequests,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
costEvents,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
@@ -22,8 +21,10 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
ensureRuntimeServicesForRun,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
@@ -46,6 +48,37 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
|
||||
const heartbeatRunListColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
status: heartbeatRuns.status,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
error: heartbeatRuns.error,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
exitCode: heartbeatRuns.exitCode,
|
||||
signal: heartbeatRuns.signal,
|
||||
usageJson: heartbeatRuns.usageJson,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
sessionIdBefore: heartbeatRuns.sessionIdBefore,
|
||||
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
||||
logStore: heartbeatRuns.logStore,
|
||||
logRef: heartbeatRuns.logRef,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
logSha256: heartbeatRuns.logSha256,
|
||||
logCompressed: heartbeatRuns.logCompressed,
|
||||
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
|
||||
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
externalRunId: heartbeatRuns.externalRunId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
} as const;
|
||||
|
||||
function appendExcerpt(prev: string, chunk: string) {
|
||||
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
||||
}
|
||||
@@ -779,6 +812,9 @@ export function heartbeatService(db: Db) {
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
|
||||
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
|
||||
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId: run.companyId,
|
||||
runId: run.id,
|
||||
@@ -788,8 +824,8 @@ export function heartbeatService(db: Db) {
|
||||
stream: event.stream,
|
||||
level: event.level,
|
||||
color: event.color,
|
||||
message: event.message,
|
||||
payload: event.payload,
|
||||
message: sanitizedMessage,
|
||||
payload: sanitizedPayload,
|
||||
});
|
||||
|
||||
publishLiveEvent({
|
||||
@@ -803,8 +839,8 @@ export function heartbeatService(db: Db) {
|
||||
stream: event.stream ?? null,
|
||||
level: event.level ?? null,
|
||||
color: event.color ?? null,
|
||||
message: event.message ?? null,
|
||||
payload: event.payload ?? null,
|
||||
message: sanitizedMessage ?? null,
|
||||
payload: sanitizedPayload ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -993,8 +1029,8 @@ export function heartbeatService(db: Db) {
|
||||
.where(eq(agentRuntimeState.agentId, agent.id));
|
||||
|
||||
if (additionalCostCents > 0 || hasTokenUsage) {
|
||||
await db.insert(costEvents).values({
|
||||
companyId: agent.companyId,
|
||||
const costs = costService(db);
|
||||
await costs.createEvent(agent.companyId, {
|
||||
agentId: agent.id,
|
||||
provider: result.provider ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
@@ -1004,16 +1040,6 @@ export function heartbeatService(db: Db) {
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (additionalCostCents > 0) {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agent.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function startNextQueuedRunForAgent(agentId: string) {
|
||||
@@ -1303,21 +1329,23 @@ export function heartbeatService(db: Db) {
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
|
||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
|
||||
const sanitizedChunk = redactCurrentUserText(chunk);
|
||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
if (handle) {
|
||||
await runLogStore.append(handle, {
|
||||
stream,
|
||||
chunk,
|
||||
ts: new Date().toISOString(),
|
||||
chunk: sanitizedChunk,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
|
||||
const payloadChunk =
|
||||
chunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
||||
? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
|
||||
: chunk;
|
||||
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
||||
? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
|
||||
: sanitizedChunk;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: run.companyId,
|
||||
@@ -1325,9 +1353,10 @@ export function heartbeatService(db: Db) {
|
||||
payload: {
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
ts,
|
||||
stream,
|
||||
chunk: payloadChunk,
|
||||
truncated: payloadChunk.length !== chunk.length,
|
||||
truncated: payloadChunk.length !== sanitizedChunk.length,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1518,7 +1547,9 @@ export function heartbeatService(db: Db) {
|
||||
error:
|
||||
outcome === "succeeded"
|
||||
? null
|
||||
: adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||
: redactCurrentUserText(
|
||||
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||
),
|
||||
errorCode:
|
||||
outcome === "timed_out"
|
||||
? "timeout"
|
||||
@@ -1585,7 +1616,7 @@ export function heartbeatService(db: Db) {
|
||||
}
|
||||
await finalizeAgentStatus(agent.id, outcome);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown adapter failure";
|
||||
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
|
||||
logger.error({ err, runId }, "heartbeat execution failed");
|
||||
|
||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||
@@ -2260,9 +2291,9 @@ export function heartbeatService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, agentId?: string, limit?: number) => {
|
||||
list: async (companyId: string, agentId?: string, limit?: number) => {
|
||||
const query = db
|
||||
.select()
|
||||
.select(heartbeatRunListColumns)
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
agentId
|
||||
@@ -2271,10 +2302,11 @@ export function heartbeatService(db: Db) {
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
|
||||
if (limit) {
|
||||
return query.limit(limit);
|
||||
}
|
||||
return query;
|
||||
const rows = limit ? await query.limit(limit) : await query;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
resultJson: summarizeHeartbeatRunResultJson(row.resultJson),
|
||||
}));
|
||||
},
|
||||
|
||||
getRun,
|
||||
@@ -2370,6 +2402,7 @@ export function heartbeatService(db: Db) {
|
||||
store: run.logStore,
|
||||
logRef: run.logRef,
|
||||
...result,
|
||||
content: redactCurrentUserText(result.content),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
30
server/src/services/issue-goal-fallback.ts
Normal file
30
server/src/services/issue-goal-fallback.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
type MaybeId = string | null | undefined;
|
||||
|
||||
export function resolveIssueGoalId(input: {
|
||||
projectId: MaybeId;
|
||||
goalId: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
if (!input.projectId && !input.goalId) {
|
||||
return input.defaultGoalId ?? null;
|
||||
}
|
||||
return input.goalId ?? null;
|
||||
}
|
||||
|
||||
export function resolveNextIssueGoalId(input: {
|
||||
currentProjectId: MaybeId;
|
||||
currentGoalId: MaybeId;
|
||||
projectId?: MaybeId;
|
||||
goalId?: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
const projectId =
|
||||
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
||||
const goalId =
|
||||
input.goalId !== undefined ? input.goalId : input.currentGoalId;
|
||||
|
||||
if (!projectId && !goalId) {
|
||||
return input.defaultGoalId ?? null;
|
||||
}
|
||||
return goalId ?? null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user