Compare commits

...

48 Commits

Author SHA1 Message Date
Dotta
56aeddfa1c Fix dev migration prompt and embedded db:migrate 2026-03-10 15:31:05 -05:00
Dotta
42c8aca5c0 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  Fix approvals service idempotency test
  Add workspace strategy plan doc
  Copy git hooks during worktree init
2026-03-10 15:09:00 -05:00
Dotta
00495d3d89 Merge pull request #534 from paperclipai/fix/approval-service-idempotency
Fix approvals service idempotency test
2026-03-10 15:07:45 -05:00
Dotta
a613435249 Fix approvals service idempotency test 2026-03-10 15:05:19 -05:00
Dotta
576b408682 Merge pull request #532 from paperclipai/feature/worktree-init-copy-hooks
Copy git hooks during worktree init
2026-03-10 14:58:18 -05:00
Dotta
193b7c0570 Add workspace strategy plan doc 2026-03-10 14:57:18 -05:00
Dotta
93a8b55ff8 Copy git hooks during worktree init 2026-03-10 14:55:35 -05:00
Dotta
24a553c255 doc for workspace strategy 2026-03-10 14:52:43 -05:00
Dotta
2332a79e0b Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  updating paths
  Rebind seeded project workspaces to the current worktree
  Add command-based worktree provisioning
  Refine project and agent configuration UI
  Add configuration tabs to project and agent pages
  Add project-first execution workspace policies
  Add worktree-aware workspace runtime support
2026-03-10 14:47:50 -05:00
Dotta
65af1d77a4 Merge pull request #530 from paperclipai/feature/workspace-runtime-support
Add issue worktree runtime support
2026-03-10 14:46:29 -05:00
Dotta
b0b7ec779a updating paths 2026-03-10 14:43:34 -05:00
Dotta
859c82aa12 Merge remote-tracking branch 'public-gh/master' into feature/workspace-runtime-support
* public-gh/master:
  Rebind seeded project workspaces to the current worktree
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
2026-03-10 14:19:11 -05:00
Dotta
6fd29e05ad Merge pull request #522 from paperclipai/feature/worktree-rebind-seeded-workspaces
Rebind seeded project workspaces to the current worktree
2026-03-10 13:54:50 -05:00
Dotta
12216b5cc6 Rebind seeded project workspaces to the current worktree 2026-03-10 13:50:29 -05:00
Dotta
0c525febf2 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  Fix doctor summary after repairs
  Fix worktree minimal clone startup
  Add minimal worktree seed mode
  Add worktree init CLI for isolated development instances
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
  Apply suggestions from code review
  fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 13:41:47 -05:00
Dotta
b0fe48b730 Revert "ui: move settings to footer icon beside theme toggle"
This reverts commit f3a9b6de21.
2026-03-10 13:41:37 -05:00
Dotta
f3a9b6de21 ui: move settings to footer icon beside theme toggle 2026-03-10 13:23:35 -05:00
Dotta
31561724f7 Merge pull request #491 from lazmo88/fix/parentid-filter-issues-list
fix: wire parentId query filter into issues list endpoint
2026-03-10 13:07:20 -05:00
Dotta
c363428966 Merge pull request #517 from paperclipai/feature/worktree-seed-secrets-key
Copy seeded secrets key into worktree instances
2026-03-10 12:59:54 -05:00
Dotta
f783f66866 Merge pull request #495 from subhendukundu/feat/configurable-attachment-types
feat: make attachment content types configurable via env var
2026-03-10 12:58:15 -05:00
Dotta
deec68ab16 Copy seeded secrets key into worktree instances 2026-03-10 12:57:53 -05:00
Dotta
6733a6cd7e Merge pull request #502 from davidahmann/codex/issue-499-approval-idempotency
Make approval resolution retries idempotent
2026-03-10 12:56:30 -05:00
Dotta
dfbb4f1ccb Add command-based worktree provisioning 2026-03-10 12:42:36 -05:00
David Ahmann
9c68c1b80b server: make approval retries idempotent (#499) 2026-03-10 12:00:29 -04:00
Dotta
e94ce47ba5 Refine project and agent configuration UI 2026-03-10 10:58:43 -05:00
Dotta
6186eba098 Add configuration tabs to project and agent pages 2026-03-10 10:58:43 -05:00
Dotta
b83a87f42f Add project-first execution workspace policies 2026-03-10 10:58:43 -05:00
Dotta
3120c72372 Add worktree-aware workspace runtime support 2026-03-10 10:58:38 -05:00
Dotta
7934952a77 Merge pull request #496 from paperclipai/feature/worktree-development-tools
feat(cli): add isolated worktree-local Paperclip instance tools
2026-03-10 10:56:36 -05:00
Dotta
d9574fea71 Fix doctor summary after repairs 2026-03-10 10:13:05 -05:00
Dotta
83738b45cd Fix worktree minimal clone startup 2026-03-10 10:13:05 -05:00
Dotta
4a67db6a4d Add minimal worktree seed mode 2026-03-10 10:13:05 -05:00
Dotta
0704854926 Add worktree init CLI for isolated development instances 2026-03-10 10:13:05 -05:00
Subhendu Kundu
1959badde7 fix: address review feedback — stale error message and * wildcard
- assets.ts: change "Image exceeds" to "File exceeds" in size-limit error
- attachment-types.ts: handle plain "*" as allow-all wildcard pattern
- Add test for "*" wildcard (12 tests total)
2026-03-10 20:01:08 +05:30
Subhendu Kundu
3ff07c23d2 Update server/src/routes/assets.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 19:54:42 +05:30
Subhendu Kundu
dec02225f1 feat: make attachment content types configurable via env var
Add PAPERCLIP_ALLOWED_ATTACHMENT_TYPES env var to configure allowed
MIME types for issue attachments and asset uploads. Supports exact
types (application/pdf) and wildcard patterns (image/*, text/*).

Falls back to the existing image-only defaults when the env var is
unset, preserving backward compatibility.

- Extract shared module `attachment-types.ts` with `isAllowedContentType()`
  and `matchesContentType()` (pure, testable)
- Update `routes/issues.ts` and `routes/assets.ts` to use shared module
- Add unit tests for parsing and wildcard matching

Closes #487
2026-03-10 19:40:22 +05:30
Claude
f6f5fee200 fix: wire parentId query filter into issues list endpoint
The parentId parameter on GET /api/companies/:companyId/issues was
silently ignored — the filter was never extracted from the query string,
never passed to the service layer, and the IssueFilters type did not
include it. All other filters (status, assigneeAgentId, projectId, etc.)
worked correctly.

This caused subtask lookups to return every issue in the company instead
of only children of the specified parent.

Changes:
- Add parentId to IssueFilters interface
- Add eq(issues.parentId, filters.parentId) condition in list()
- Extract parentId from req.query in the route handler

Fixes: LAS-101
2026-03-10 15:54:31 +02:00
Dotta
49b9511889 Merge pull request #485 from jknair/fix/strip-claudecode-env-from-child-processes
fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 07:25:30 -05:00
Dotta
1a53567cb6 Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 07:24:48 -05:00
Jayakrishnan
9248881d42 fix(adapter-utils): strip Claude Code env vars from child processes
When the Paperclip server is started from within a Claude Code session
(e.g. `npx paperclipai run` in a Claude Code terminal), the `CLAUDECODE`
and related env vars (`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SESSION`,
`CLAUDE_CODE_PARENT_SESSION`) leak into `process.env`. Since
`runChildProcess()` spreads `process.env` into the child environment,
every spawned `claude` CLI process inherits these vars and immediately
exits with: "Claude Code cannot be launched inside another Claude Code
session."

This is particularly disruptive for the `claude-local` adapter, where
every agent run spawns a `claude` child process. A single contaminated
server start (or cron job that inherits the env) silently breaks all
agent executions until the server is restarted in a clean environment.

The fix deletes the four known Claude Code nesting-guard env vars from
the merged environment before passing it to `spawn()`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:01:46 +00:00
Dotta
ef978dd601 Merge pull request #446 from paperclipai/codex/pr-report-skill
feat: add pr-report skill
2026-03-09 17:05:35 -05:00
Dotta
fbf9d5714f feat: add pr-report skill 2026-03-09 17:01:45 -05:00
Dotta
8ac064499f Merge pull request #445 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:45:02 -05:00
Dotta
cbbf695c35 release files 2026-03-09 16:43:53 -05:00
Dotta
7e8908afa2 chore: release v0.3.0 2026-03-09 16:31:12 -05:00
Dotta
58d4d04e99 Merge pull request #444 from paperclipai/release/0.3.0
Release/0.3.0
2026-03-09 16:20:22 -05:00
Dotta
c672b71f7f Refresh bootstrap gate while setup is pending 2026-03-09 16:13:15 -05:00
Dotta
01c5a6f198 Unblock canary onboard smoke bootstrap 2026-03-09 16:06:16 -05:00
114 changed files with 21650 additions and 716 deletions

View File

@@ -1,5 +0,0 @@
---
"@paperclipai/shared": minor
---
Add support for Pi local adapter in constants and onboarding UI.

View File

@@ -1,5 +1,26 @@
# paperclipai
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
- @paperclipai/server@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "paperclipai",
"version": "0.2.7",
"version": "0.3.0",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
@@ -47,6 +47,7 @@
"drizzle-orm": "0.38.4",
"dotenv": "^17.0.1",
"commander": "^13.1.0",
"embedded-postgres": "^18.1.0-beta.16",
"picocolors": "^1.1.1"
},
"devDependencies": {

View File

@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { doctor } from "../commands/doctor.js";
import { writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createTempConfig(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
const configPath = path.join(root, ".paperclip", "config.json");
const runtimeRoot = path.join(root, "runtime");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3199,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
writeConfig(config, configPath);
return configPath;
}
describe("doctor", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
const configPath = createTempConfig();
const summary = await doctor({
config: configPath,
repair: true,
yes: true,
});
expect(summary.failed).toBe(0);
expect(summary.warned).toBe(0);
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
});
});

View File

@@ -0,0 +1,251 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
sanitizeWorktreeInstanceId,
} from "../commands/worktree-lib.js";
import type { PaperclipConfig } from "../config/schema.js";
function buildSourceConfig(): PaperclipConfig {
return {
$meta: {
version: 1,
updatedAt: "2026-03-09T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/main/db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/main/backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/main/logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: ["localhost"],
serveUi: true,
},
auth: {
baseUrlMode: "explicit",
publicBaseUrl: "http://127.0.0.1:3100",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/tmp/main/storage",
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: "/tmp/main/secrets/master.key",
},
},
};
}
describe("worktree helpers", () => {
it("sanitizes instance ids", () => {
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
});
it("builds isolated config and env paths for a worktree", () => {
const paths = resolveWorktreeLocalPaths({
cwd: "/tmp/paperclip-feature",
homeDir: "/tmp/paperclip-worktrees",
instanceId: "feature-worktree-support",
});
const config = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths,
serverPort: 3110,
databasePort: 54339,
now: new Date("2026-03-09T12:00:00.000Z"),
});
expect(config.database.embeddedPostgresDataDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
);
expect(config.database.embeddedPostgresPort).toBe(54339);
expect(config.server.port).toBe(3110);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
expect(config.storage.localDisk.baseDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths);
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
expect(minimal.excludedTables).toContain("heartbeat_runs");
expect(minimal.excludedTables).toContain("heartbeat_run_events");
expect(minimal.excludedTables).toContain("workspace_runtime_services");
expect(minimal.excludedTables).toContain("agent_task_sessions");
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
it("copies shared git hooks into a linked worktree git dir", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(tempRoot, "repo-feature");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
fs.chmodSync(sourceHookPath, 0o755);
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
expect(copied).toMatchObject({
sourceHooksPath: resolvedSourceHooksDir,
targetHooksPath: resolvedTargetHooksDir,
copied: true,
});
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
} finally {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});

View File

@@ -75,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const existingAdminCount = await db
.select()
@@ -122,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}

View File

@@ -66,28 +66,40 @@ export async function doctor(opts: {
printResult(deploymentAuthResult);
// 3. Agent JWT check
const jwtResult = agentJwtSecretCheck(opts.config);
results.push(jwtResult);
printResult(jwtResult);
await maybeRepair(jwtResult, opts);
results.push(
await runRepairableCheck({
run: () => agentJwtSecretCheck(opts.config),
configPath,
opts,
}),
);
// 4. Secrets adapter check
const secretsResult = secretsCheck(config, configPath);
results.push(secretsResult);
printResult(secretsResult);
await maybeRepair(secretsResult, opts);
results.push(
await runRepairableCheck({
run: () => secretsCheck(config, configPath),
configPath,
opts,
}),
);
// 5. Storage check
const storageResult = storageCheck(config, configPath);
results.push(storageResult);
printResult(storageResult);
await maybeRepair(storageResult, opts);
results.push(
await runRepairableCheck({
run: () => storageCheck(config, configPath),
configPath,
opts,
}),
);
// 6. Database check
const dbResult = await databaseCheck(config, configPath);
results.push(dbResult);
printResult(dbResult);
await maybeRepair(dbResult, opts);
results.push(
await runRepairableCheck({
run: () => databaseCheck(config, configPath),
configPath,
opts,
}),
);
// 7. LLM check
const llmResult = await llmCheck(config);
@@ -95,10 +107,13 @@ export async function doctor(opts: {
printResult(llmResult);
// 8. Log directory check
const logResult = logCheck(config, configPath);
results.push(logResult);
printResult(logResult);
await maybeRepair(logResult, opts);
results.push(
await runRepairableCheck({
run: () => logCheck(config, configPath),
configPath,
opts,
}),
);
// 9. Port check
const portResult = await portCheck(config);
@@ -120,9 +135,9 @@ function printResult(result: CheckResult): void {
async function maybeRepair(
result: CheckResult,
opts: { repair?: boolean; yes?: boolean },
): Promise<void> {
if (result.status === "pass" || !result.canRepair || !result.repair) return;
if (!opts.repair) return;
): Promise<boolean> {
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
if (!opts.repair) return false;
let shouldRepair = opts.yes;
if (!shouldRepair) {
@@ -130,7 +145,7 @@ async function maybeRepair(
message: `Repair "${result.name}"?`,
initialValue: true,
});
if (p.isCancel(answer)) return;
if (p.isCancel(answer)) return false;
shouldRepair = answer;
}
@@ -138,10 +153,30 @@ async function maybeRepair(
try {
await result.repair();
p.log.success(`Repaired: ${result.name}`);
return true;
} catch (err) {
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
return false;
}
async function runRepairableCheck(input: {
run: () => CheckResult | Promise<CheckResult>;
configPath: string;
opts: { repair?: boolean; yes?: boolean };
}): Promise<CheckResult> {
let result = await input.run();
printResult(result);
const repaired = await maybeRepair(result, input.opts);
if (!repaired) return result;
// Repairs may create/update the adjacent .env file or other local resources.
loadPaperclipEnvFile(input.configPath);
result = await input.run();
printResult(result);
return result;
}
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {

View File

@@ -0,0 +1,217 @@
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import { expandHomePrefix } from "../config/home.js";
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
export type WorktreeSeedPlan = {
mode: WorktreeSeedMode;
excludedTables: string[];
nullifyColumns: Record<string, string[]>;
};
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
"activity_log",
"agent_runtime_state",
"agent_task_sessions",
"agent_wakeup_requests",
"cost_events",
"heartbeat_run_events",
"heartbeat_runs",
"workspace_runtime_services",
];
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
issues: ["checkout_run_id", "execution_run_id"],
};
export type WorktreeLocalPaths = {
cwd: string;
repoConfigDir: string;
configPath: string;
envPath: string;
homeDir: string;
instanceId: string;
instanceRoot: string;
contextPath: string;
embeddedPostgresDataDir: string;
backupDir: string;
logDir: string;
secretsKeyFilePath: string;
storageDir: string;
};
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
}
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
if (mode === "full") {
return {
mode,
excludedTables: [],
nullifyColumns: {},
};
}
return {
mode,
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
nullifyColumns: {
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
},
};
}
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
export function sanitizeWorktreeInstanceId(rawValue: string): string {
const trimmed = rawValue.trim().toLowerCase();
const normalized = trimmed
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
return normalized || "worktree";
}
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
}
export function resolveWorktreeLocalPaths(opts: {
cwd: string;
homeDir?: string;
instanceId: string;
}): WorktreeLocalPaths {
const cwd = path.resolve(opts.cwd);
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
const repoConfigDir = path.resolve(cwd, ".paperclip");
return {
cwd,
repoConfigDir,
configPath: path.resolve(repoConfigDir, "config.json"),
envPath: path.resolve(repoConfigDir, ".env"),
homeDir,
instanceId: opts.instanceId,
instanceRoot,
contextPath: path.resolve(homeDir, "context.json"),
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
backupDir: path.resolve(instanceRoot, "data", "backups"),
logDir: path.resolve(instanceRoot, "logs"),
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
storageDir: path.resolve(instanceRoot, "data", "storage"),
};
}
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
return rawUrl;
}
}
export function buildWorktreeConfig(input: {
sourceConfig: PaperclipConfig | null;
paths: WorktreeLocalPaths;
serverPort: number;
databasePort: number;
now?: Date;
}): PaperclipConfig {
const { sourceConfig, paths, serverPort, databasePort } = input;
const nowIso = (input.now ?? new Date()).toISOString();
const source = sourceConfig;
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
return {
$meta: {
version: 1,
updatedAt: nowIso,
source: "configure",
},
...(source?.llm ? { llm: source.llm } : {}),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
embeddedPostgresPort: databasePort,
backup: {
enabled: source?.database.backup.enabled ?? true,
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
retentionDays: source?.database.backup.retentionDays ?? 30,
dir: paths.backupDir,
},
},
logging: {
mode: source?.logging.mode ?? "file",
logDir: paths.logDir,
},
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],
serveUi: source?.server.serveUi ?? true,
},
auth: {
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
disableSignUp: source?.auth.disableSignUp ?? false,
},
storage: {
provider: source?.storage.provider ?? "local_disk",
localDisk: {
baseDir: paths.storageDir,
},
s3: {
bucket: source?.storage.s3.bucket ?? "paperclip",
region: source?.storage.s3.region ?? "us-east-1",
endpoint: source?.storage.s3.endpoint,
prefix: source?.storage.s3.prefix ?? "",
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
},
},
secrets: {
provider: source?.secrets.provider ?? "local_encrypted",
strictMode: source?.secrets.strictMode ?? false,
localEncrypted: {
keyFilePath: paths.secretsKeyFilePath,
},
},
};
}
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
return {
PAPERCLIP_HOME: paths.homeDir,
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
};
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function formatShellExports(entries: Record<string, string>): string {
return Object.entries(entries)
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
.join("\n");
}

View File

@@ -0,0 +1,690 @@
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { createServer } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { eq } from "drizzle-orm";
import {
applyPendingMigrations,
createDb,
ensurePostgresDatabase,
formatDatabaseBackupResult,
projectWorkspaces,
runDatabaseBackup,
runDatabaseRestore,
} from "@paperclipai/db";
import type { Command } from "commander";
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
import { expandHomePrefix } from "../config/home.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
DEFAULT_WORKTREE_HOME,
formatShellExports,
isWorktreeSeedMode,
resolveSuggestedWorktreeName,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
sanitizeWorktreeInstanceId,
type WorktreeSeedMode,
type WorktreeLocalPaths,
} from "./worktree-lib.js";
type WorktreeInitOptions = {
name?: string;
instance?: string;
home?: string;
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
serverPort?: number;
dbPort?: number;
seed?: boolean;
seedMode?: string;
force?: boolean;
};
type WorktreeEnvOptions = {
config?: string;
json?: boolean;
};
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
type EmbeddedPostgresHandle = {
port: number;
startedByThisProcess: boolean;
stop: () => Promise<void>;
};
type GitWorkspaceInfo = {
root: string;
commonDir: string;
gitDir: string;
hooksPath: string;
};
type CopiedGitHooksResult = {
sourceHooksPath: string;
targetHooksPath: string;
copied: boolean;
};
type SeedWorktreeDatabaseResult = {
backupSummary: string;
reboundWorkspaces: Array<{
name: string;
fromCwd: string;
toCwd: string;
}>;
};
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
const port = Number(lines[3]?.trim());
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
if (!Number.isInteger(pid) || pid <= 0) return null;
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
async function isPortAvailable(port: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
async function findAvailablePort(preferredPort: number, reserved = new Set<number>()): Promise<number> {
let port = Math.max(1, Math.trunc(preferredPort));
while (reserved.has(port) || !(await isPortAvailable(port))) {
port += 1;
}
return port;
}
function detectGitBranchName(cwd: string): string | null {
try {
const value = execFileSync("git", ["branch", "--show-current"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return nonEmpty(value);
} catch {
return null;
}
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
gitDir: path.resolve(root, gitDirRaw),
hooksPath: path.resolve(root, hooksPathRaw),
};
} catch {
return null;
}
}
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
if (!existsSync(sourceDir)) return false;
const entries = readdirSync(sourceDir, { withFileTypes: true });
if (entries.length === 0) return false;
mkdirSync(targetDir, { recursive: true });
let copied = false;
for (const entry of entries) {
const sourcePath = path.resolve(sourceDir, entry.name);
const targetPath = path.resolve(targetDir, entry.name);
if (entry.isDirectory()) {
mkdirSync(targetPath, { recursive: true });
copyDirectoryContents(sourcePath, targetPath);
copied = true;
continue;
}
if (entry.isSymbolicLink()) {
rmSync(targetPath, { recursive: true, force: true });
symlinkSync(readlinkSync(sourcePath), targetPath);
copied = true;
continue;
}
copyFileSync(sourcePath, targetPath);
try {
chmodSync(targetPath, statSync(sourcePath).mode & 0o777);
} catch {
// best effort
}
copied = true;
}
return copied;
}
export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null {
const workspace = detectGitWorkspaceInfo(cwd);
if (!workspace) return null;
const sourceHooksPath = workspace.hooksPath;
const targetHooksPath = path.resolve(workspace.gitDir, "hooks");
if (sourceHooksPath === targetHooksPath) {
return {
sourceHooksPath,
targetHooksPath,
copied: false,
};
}
return {
sourceHooksPath,
targetHooksPath,
copied: copyDirectoryContents(sourceHooksPath, targetHooksPath),
};
}
export function rebindWorkspaceCwd(input: {
sourceRepoRoot: string;
targetRepoRoot: string;
workspaceCwd: string;
}): string | null {
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
const targetRepoRoot = path.resolve(input.targetRepoRoot);
const workspaceCwd = path.resolve(input.workspaceCwd);
const relative = path.relative(sourceRepoRoot, workspaceCwd);
if (!relative || relative === "") {
return targetRepoRoot;
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return null;
}
return path.resolve(targetRepoRoot, relative);
}
async function rebindSeededProjectWorkspaces(input: {
targetConnectionString: string;
currentCwd: string;
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
if (!targetRepo) return [];
const db = createDb(input.targetConnectionString);
const closableDb = db as typeof db & {
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
};
try {
const rows = await db
.select({
id: projectWorkspaces.id,
name: projectWorkspaces.name,
cwd: projectWorkspaces.cwd,
})
.from(projectWorkspaces);
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
for (const row of rows) {
const workspaceCwd = nonEmpty(row.cwd);
if (!workspaceCwd) continue;
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
if (!sourceRepo) continue;
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
const reboundCwd = rebindWorkspaceCwd({
sourceRepoRoot: sourceRepo.root,
targetRepoRoot: targetRepo.root,
workspaceCwd,
});
if (!reboundCwd) continue;
const normalizedCurrent = path.resolve(workspaceCwd);
if (reboundCwd === normalizedCurrent) continue;
if (!existsSync(reboundCwd)) continue;
await db
.update(projectWorkspaces)
.set({
cwd: reboundCwd,
updatedAt: new Date(),
})
.where(eq(projectWorkspaces.id, row.id));
rebound.push({
name: row.name,
fromCwd: normalizedCurrent,
toCwd: reboundCwd,
});
}
return rebound;
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
if (opts.fromConfig) return path.resolve(opts.fromConfig);
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
}
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
if (config.database.mode === "postgres") {
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
if (!connectionString) {
throw new Error(
"Source instance uses postgres mode but has no connection string in config or adjacent .env.",
);
}
return connectionString;
}
const port = portOverride ?? config.database.embeddedPostgresPort;
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
export function copySeededSecretsKey(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
sourceEnvEntries: Record<string, string>;
targetKeyFilePath: string;
}): void {
if (input.sourceConfig.secrets.provider !== "local_encrypted") {
return;
}
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
const sourceInlineMasterKey =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY);
if (sourceInlineMasterKey) {
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
encoding: "utf8",
mode: 0o600,
});
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
return;
}
const sourceKeyFileOverride =
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE);
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
if (!existsSync(sourceKeyFilePath)) {
throw new Error(
`Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`,
);
}
copyFileSync(sourceKeyFilePath, input.targetKeyFilePath);
try {
chmodSync(input.targetKeyFilePath, 0o600);
} catch {
// best effort
}
}
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor;
try {
const mod = await import(moduleName);
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
if (runningPid) {
return {
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
startedByThisProcess: false,
stop: async () => {},
};
}
const port = await findAvailablePort(preferredPort);
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
onLog: () => {},
onError: () => {},
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
}
await instance.start();
return {
port,
startedByThisProcess: true,
stop: async () => {
await instance.stop();
},
};
}
async function seedWorktreeDatabase(input: {
sourceConfigPath: string;
sourceConfig: PaperclipConfig;
targetConfig: PaperclipConfig;
targetPaths: WorktreeLocalPaths;
instanceId: string;
seedMode: WorktreeSeedMode;
}): Promise<SeedWorktreeDatabaseResult> {
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
copySeededSecretsKey({
sourceConfigPath: input.sourceConfigPath,
sourceConfig: input.sourceConfig,
sourceEnvEntries,
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
});
let sourceHandle: EmbeddedPostgresHandle | null = null;
let targetHandle: EmbeddedPostgresHandle | null = null;
try {
if (input.sourceConfig.database.mode === "embedded-postgres") {
sourceHandle = await ensureEmbeddedPostgres(
input.sourceConfig.database.embeddedPostgresDataDir,
input.sourceConfig.database.embeddedPostgresPort,
);
}
const sourceConnectionString = resolveSourceConnectionString(
input.sourceConfig,
sourceEnvEntries,
sourceHandle?.port,
);
const backup = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
retentionDays: 7,
filenamePrefix: `${input.instanceId}-seed`,
includeMigrationJournal: true,
excludeTables: seedPlan.excludedTables,
nullifyColumns: seedPlan.nullifyColumns,
});
targetHandle = await ensureEmbeddedPostgres(
input.targetConfig.database.embeddedPostgresDataDir,
input.targetConfig.database.embeddedPostgresPort,
);
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
await runDatabaseRestore({
connectionString: targetConnectionString,
backupFile: backup.backupFile,
});
await applyPendingMigrations(targetConnectionString);
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
targetConnectionString,
currentCwd: input.targetPaths.cwd,
});
return {
backupSummary: formatDatabaseBackupResult(backup),
reboundWorkspaces,
};
} finally {
if (targetHandle?.startedByThisProcess) {
await targetHandle.stop();
}
if (sourceHandle?.startedByThisProcess) {
await sourceHandle.stop();
}
}
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
cwd,
opts.name ?? detectGitBranchName(cwd) ?? undefined,
);
const seedMode = opts.seedMode ?? "minimal";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
const paths = resolveWorktreeLocalPaths({
cwd,
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
instanceId,
});
const sourceConfigPath = resolveSourceConfigPath(opts);
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) {
throw new Error(
`Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`,
);
}
if (opts.force) {
rmSync(paths.repoConfigDir, { recursive: true, force: true });
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
const serverPort = await findAvailablePort(preferredServerPort);
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
const targetConfig = buildWorktreeConfig({
sourceConfig,
paths,
serverPort,
databasePort,
});
writeConfig(targetConfig, paths.configPath);
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
ensureAgentJwtSecret(paths.configPath);
loadPaperclipEnvFile(paths.configPath);
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
let seedSummary: string | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
);
}
const spinner = p.spinner();
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
try {
const seeded = await seedWorktreeDatabase({
sourceConfigPath,
sourceConfig,
targetConfig,
targetPaths: paths,
instanceId,
seedMode,
});
seedSummary = seeded.backupSummary;
reboundWorkspaceSummary = seeded.reboundWorkspaces;
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
} catch (error) {
spinner.stop(pc.red("Failed to seed worktree database."));
throw error;
}
}
p.log.message(pc.dim(`Repo config: ${paths.configPath}`));
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
if (copiedGitHooks?.copied) {
p.log.message(
pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`),
);
}
if (seedSummary) {
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
for (const rebound of reboundWorkspaceSummary) {
p.log.message(
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
);
}
}
p.outro(
pc.green(
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
),
);
}
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const envPath = resolvePaperclipEnvFile(configPath);
const envEntries = readPaperclipEnvEntries(envPath);
const out = {
PAPERCLIP_CONFIG: configPath,
...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}),
...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}),
...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}),
...envEntries,
};
if (opts.json) {
console.log(JSON.stringify(out, null, 2));
return;
}
console.log(formatShellExports(out));
}
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
worktree
.command("init")
.description("Create repo-local config/env and an isolated instance for this worktree")
.option("--name <name>", "Display name used to derive the instance id")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
worktree
.command("env")
.description("Print shell exports for the current worktree-local Paperclip instance")
.option("-c, --config <path>", "Path to config file")
.option("--json", "Print JSON instead of shell exports")
.action(worktreeEnvCommand);
}

View File

@@ -25,13 +25,17 @@ function parseEnvFile(contents: string) {
function renderEnvFile(entries: Record<string, string>) {
const lines = [
"# Paperclip environment variables",
"# Generated by `paperclipai onboard`",
"# Generated by Paperclip CLI commands",
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
"",
];
return lines.join("\n");
}
export function resolvePaperclipEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function resolveAgentJwtEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
@@ -82,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
}
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
}
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
if (!fs.existsSync(filePath)) return {};
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
}
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
current[JWT_SECRET_ENV_KEY] = secret;
fs.writeFileSync(filePath, renderEnvFile(current), {
fs.writeFileSync(filePath, renderEnvFile(entries), {
mode: 0o600,
});
}
export function mergePaperclipEnvEntries(
entries: Record<string, string>,
filePath = resolveEnvFilePath(),
): Record<string, string> {
const current = readPaperclipEnvEntries(filePath);
const next = {
...current,
...Object.fromEntries(
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
),
};
writePaperclipEnvEntries(next, filePath);
return next;
}

View File

@@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
import { registerActivityCommands } from "./commands/client/activity.js";
import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
hasConfigOption: optionNames.has("config"),
hasContextOption: optionNames.has("context"),
});
loadPaperclipEnvFile(options.config);
});
program
@@ -132,6 +135,7 @@ registerAgentCommands(program);
registerApprovalCommands(program);
registerActivityCommands(program);
registerDashboardCommands(program);
registerWorktreeCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");

View File

@@ -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`).

View File

@@ -124,6 +124,53 @@ When a local agent run has no resolved project/session workspace, Paperclip fall
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
Instead, create a repo-local Paperclip config plus an isolated instance for the worktree:
```sh
paperclipai worktree init
```
This command:
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
- picks a free app port and embedded PostgreSQL port
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
Seed modes:
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
Print shell exports explicitly when needed:
```sh
paperclipai worktree env
# or:
eval "$(paperclipai worktree env)"
```
Useful options:
```sh
paperclipai worktree init --no-seed
paperclipai worktree init --seed-mode minimal
paperclipai worktree init --seed-mode full
paperclipai worktree init --from-instance default
paperclipai worktree init --from-data-dir ~/.paperclip
paperclipai worktree init --force
```
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
## Quick Health Checks
In another terminal:

View File

@@ -0,0 +1,62 @@
# Issue worktree support
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
- project execution workspace policy support
- issue-level execution workspace settings
- git worktree realization for isolated issue execution
- optional command-based worktree provisioning
- seeded worktree fixes for secrets key compatibility
- seeded project workspace rebinding to the current git worktree
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
## What works today
- projects can carry execution workspace policy in the backend
- issues can carry execution workspace settings in the backend
- heartbeat execution can realize isolated git worktrees
- runtime can run a project-defined provision command inside the derived worktree
- seeded worktree instances can keep local-encrypted secrets working
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
## Hidden UI entrypoints
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
- project settings:
- `ui/src/components/ProjectProperties.tsx`
- execution workspace policy controls
- git worktree base ref / branch template / parent dir
- provision / teardown command inputs
- issue creation:
- `ui/src/components/NewIssueDialog.tsx`
- isolated issue checkout toggle
- defaulting issue execution workspace settings from project policy
- issue editing:
- `ui/src/components/IssueProperties.tsx`
- issue-level workspace mode toggle
- defaulting issue execution workspace settings when project changes
- agent/runtime settings:
- `ui/src/adapters/runtime-json-fields.tsx`
- runtime services JSON field, which is part of the broader workspace-runtime support surface
## Why the UI is hidden
- the runtime behavior is still being validated
- the workflow and operator ergonomics are not final
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
## Re-enable plan
When this is ready to ship:
- re-enable the gated UI sections in the files above
- review wording and defaults for project and issue controls
- decide which agent/runtime settings should remain advanced-only
- add end-to-end product-level verification for the full UI workflow

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -1,5 +1,11 @@
# @paperclipai/adapter-utils
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-utils",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -3,6 +3,7 @@ export type {
AdapterRuntime,
UsageSummary,
AdapterBillingType,
AdapterRuntimeServiceReport,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,

View File

@@ -272,7 +272,24 @@ export async function runChildProcess(
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
// don't refuse to start with "cannot be launched inside another session".
// These vars leak in when the Paperclip server itself is started from
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
// owned by Claude Code) or when cron inherits a contaminated shell env.
const CLAUDE_CODE_NESTING_VARS = [
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION",
"CLAUDE_CODE_PARENT_SESSION",
] as const;
for (const key of CLAUDE_CODE_NESTING_VARS) {
delete rawMerged[key];
}
const mergedEnv = ensurePathInEnv(rawMerged);
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
.then((target) => {
const child = spawn(target.command, target.args, {

View File

@@ -32,6 +32,27 @@ export interface UsageSummary {
export type AdapterBillingType = "api" | "subscription" | "unknown";
export interface AdapterRuntimeServiceReport {
id?: string | null;
projectId?: string | null;
projectWorkspaceId?: string | null;
issueId?: string | null;
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId?: string | null;
serviceName: string;
status?: "starting" | "running" | "stopped" | "failed";
lifecycle?: "shared" | "ephemeral";
reuseKey?: string | null;
command?: string | null;
cwd?: string | null;
port?: number | null;
url?: string | null;
providerRef?: string | null;
ownerAgentId?: string | null;
stopPolicy?: Record<string, unknown> | null;
healthStatus?: "unknown" | "healthy" | "unhealthy";
}
export interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
billingType?: AdapterBillingType | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
}
@@ -208,6 +230,12 @@ export interface CreateConfigValues {
envBindings: Record<string, unknown>;
url: string;
bootstrapPrompt: string;
payloadTemplateJson?: string;
workspaceStrategyType?: string;
workspaceBaseRef?: string;
workspaceBranchTemplate?: string;
worktreeParentDir?: string;
runtimeServicesJson?: string;
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-claude-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-claude-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -25,8 +25,13 @@ Core fields:
- command (string, optional): defaults to "claude"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;

View File

@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (Object.keys(env).length > 0) ac.env = env;
ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-codex-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-codex-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -31,6 +31,8 @@ Core fields:
- command (string, optional): defaults to "codex"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
@@ -40,4 +42,5 @@ Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument).
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`;

View File

@@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
? context.paperclipRuntimeServiceIntents.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
? context.paperclipRuntimeServices.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceStrategy) {
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
if (runtimeServices.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
}
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}

View File

@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
typeof v.dangerouslyBypassSandbox === "boolean"
? v.dangerouslyBypassSandbox
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
if (v.workspaceStrategyType === "git_worktree") {
ac.workspaceStrategy = {
type: "git_worktree",
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
};
}
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-cursor-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-cursor-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -0,0 +1,12 @@
# @paperclipai/adapter-openclaw-gateway
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-openclaw-gateway",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -31,6 +31,7 @@ Gateway connect identity fields:
Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
@@ -39,4 +40,15 @@ Request behavior fields:
Session routing fields:
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
Standard outbound payload additions:
- paperclip (object): standardized Paperclip context added to every gateway agent request
- paperclip.workspace (object, optional): resolved execution workspace for this run
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
Standard result metadata supported:
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
- meta.previewUrl (string, optional): shorthand single preview URL
- meta.previewUrls (string[], optional): shorthand multiple preview URLs
`;

View File

@@ -1,4 +1,8 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import type {
AdapterExecutionContext,
AdapterExecutionResult,
AdapterRuntimeServiceReport,
} from "@paperclipai/adapter-utils";
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
import crypto, { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
@@ -411,6 +415,58 @@ function appendWakeText(baseText: string, wakeText: string): string {
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
}
function buildStandardPaperclipPayload(
ctx: AdapterExecutionContext,
wakePayload: WakePayload,
paperclipEnv: Record<string, string>,
payloadTemplate: Record<string, unknown>,
): Record<string, unknown> {
const templatePaperclip = parseObject(payloadTemplate.paperclip);
const workspace = asRecord(ctx.context.paperclipWorkspace);
const workspaces = Array.isArray(ctx.context.paperclipWorkspaces)
? ctx.context.paperclipWorkspaces.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
: [];
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
? ctx.context.paperclipRuntimeServiceIntents.filter(
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
)
: [];
const standardPaperclip: Record<string, unknown> = {
runId: ctx.runId,
companyId: ctx.agent.companyId,
agentId: ctx.agent.id,
agentName: ctx.agent.name,
taskId: wakePayload.taskId,
issueId: wakePayload.issueId,
issueIds: wakePayload.issueIds,
wakeReason: wakePayload.wakeReason,
wakeCommentId: wakePayload.wakeCommentId,
approvalId: wakePayload.approvalId,
approvalStatus: wakePayload.approvalStatus,
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
};
if (workspace) {
standardPaperclip.workspace = workspace;
}
if (workspaces.length > 0) {
standardPaperclip.workspaces = workspaces;
}
if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) {
standardPaperclip.workspaceRuntime = {
...configuredWorkspaceRuntime,
...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}),
};
}
return {
...templatePaperclip,
...standardPaperclip,
};
}
function normalizeUrl(input: string): URL | null {
try {
return new URL(input);
@@ -835,6 +891,91 @@ function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined
};
}
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
if (!meta) return [];
const reports: AdapterRuntimeServiceReport[] = [];
const runtimeServices = Array.isArray(meta.runtimeServices)
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
: [];
for (const entry of runtimeServices) {
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
if (!serviceName) continue;
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
const status =
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
? rawStatus
: "running";
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
const scopeType =
rawScopeType === "project_workspace" ||
rawScopeType === "execution_workspace" ||
rawScopeType === "agent"
? rawScopeType
: "run";
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
const healthStatus =
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
? rawHealth
: status === "running"
? "healthy"
: "unknown";
reports.push({
id: nonEmpty(entry.id),
projectId: nonEmpty(entry.projectId),
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
issueId: nonEmpty(entry.issueId),
scopeType,
scopeId: nonEmpty(entry.scopeId),
serviceName,
status,
lifecycle,
reuseKey: nonEmpty(entry.reuseKey),
command: nonEmpty(entry.command),
cwd: nonEmpty(entry.cwd),
port: parseOptionalPositiveInteger(entry.port),
url: nonEmpty(entry.url),
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
ownerAgentId: nonEmpty(entry.ownerAgentId),
stopPolicy: asRecord(entry.stopPolicy),
healthStatus,
});
}
const previewUrl = nonEmpty(meta.previewUrl);
if (previewUrl) {
reports.push({
serviceName: "preview",
status: "running",
lifecycle: "ephemeral",
scopeType: "run",
url: previewUrl,
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
healthStatus: "healthy",
});
}
const previewUrls = Array.isArray(meta.previewUrls)
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: [];
previewUrls.forEach((url, index) => {
reports.push({
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
status: "running",
lifecycle: "ephemeral",
scopeType: "run",
url,
providerRef: `${url}#${index}`,
healthStatus: "healthy",
});
});
return reports;
}
function extractResultText(value: unknown): string | null {
const record = asRecord(value);
if (!record) return null;
@@ -924,9 +1065,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
const agentParams: Record<string, unknown> = {
...payloadTemplate,
paperclip: paperclipPayload,
message,
sessionKey,
idempotencyKey: ctx.runId,
@@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
null;
const summary = summaryFromEvents || summaryFromPayload || null;
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
const agentMeta = asRecord(meta?.agentMeta);
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
const acceptedResult = asRecord(acceptedPayload?.result);
const latestPayload = asRecord(latestResultPayload);
const latestResult = asRecord(latestPayload?.result);
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
const mergedMeta = {
...(acceptedMeta ?? {}),
...(latestMeta ?? {}),
};
const agentMeta =
asRecord(mergedMeta.agentMeta) ??
asRecord(acceptedMeta?.agentMeta) ??
asRecord(latestMeta?.agentMeta);
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
await ctx.onLog(
"stdout",
@@ -1209,6 +1364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(usage ? { usage } : {}),
...(costUsd > 0 ? { costUsd } : {}),
resultJson: asRecord(latestResultPayload),
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
...(summary ? { summary } : {}),
};
} catch (err) {

View File

@@ -1,5 +1,17 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
@@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
ac.sessionKeyStrategy = "issue";
ac.role = "operator";
ac.scopes = ["operator.admin"];
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
if (runtimeServices && Array.isArray(runtimeServices.services)) {
ac.workspaceRuntime = runtimeServices;
}
return ac;
}

View File

@@ -1,5 +1,16 @@
# @paperclipai/adapter-opencode-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-opencode-local",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -0,0 +1,12 @@
# @paperclipai/adapter-pi-local
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies
- @paperclipai/adapter-utils@0.3.0

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-pi-local",
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,5 +1,17 @@
# @paperclipai/db
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/db",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import postgres from "postgres";
export type RunDatabaseBackupOptions = {
@@ -9,6 +9,9 @@ export type RunDatabaseBackupOptions = {
retentionDays: number;
filenamePrefix?: string;
connectTimeoutSeconds?: number;
includeMigrationJournal?: boolean;
excludeTables?: string[];
nullifyColumns?: Record<string, string[]>;
};
export type RunDatabaseBackupResult = {
@@ -17,6 +20,50 @@ export type RunDatabaseBackupResult = {
prunedCount: number;
};
export type RunDatabaseRestoreOptions = {
connectionString: string;
backupFile: string;
connectTimeoutSeconds?: number;
};
type SequenceDefinition = {
sequence_schema: string;
sequence_name: string;
data_type: string;
start_value: string;
minimum_value: string;
maximum_value: string;
increment: string;
cycle_option: "YES" | "NO";
owner_schema: string | null;
owner_table: string | null;
owner_column: string | null;
};
type TableDefinition = {
schema_name: string;
tablename: string;
};
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
function sanitizeRestoreErrorMessage(error: unknown): string {
if (error && typeof error === "object") {
const record = error as Record<string, unknown>;
const firstLine = typeof record.message === "string"
? record.message.split(/\r?\n/, 1)[0]?.trim()
: "";
const detail = typeof record.detail === "string" ? record.detail.trim() : "";
const severity = typeof record.severity === "string" ? record.severity.trim() : "";
const message = firstLine || detail || (error instanceof Error ? error.message : String(error));
return severity ? `${severity}: ${message}` : message;
}
return error instanceof Error ? error.message : String(error);
}
function timestamp(date: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
@@ -47,10 +94,60 @@ function formatBackupSize(sizeBytes: number): string {
return `${(sizeBytes / (1024 * 1024)).toFixed(1)}M`;
}
function formatSqlLiteral(value: string): string {
const sanitized = value.replace(/\u0000/g, "");
let tag = "$paperclip$";
while (sanitized.includes(tag)) {
tag = `$paperclip_${Math.random().toString(36).slice(2, 8)}$`;
}
return `${tag}${sanitized}${tag}`;
}
function normalizeTableNameSet(values: string[] | undefined): Set<string> {
return new Set(
(values ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0),
);
}
function normalizeNullifyColumnMap(values: Record<string, string[]> | undefined): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
if (!values) return out;
for (const [tableName, columns] of Object.entries(values)) {
const normalizedTable = tableName.trim();
if (normalizedTable.length === 0) continue;
const normalizedColumns = new Set(
columns
.map((column) => column.trim())
.filter((column) => column.length > 0),
);
if (normalizedColumns.size > 0) {
out.set(normalizedTable, normalizedColumns);
}
}
return out;
}
function quoteIdentifier(value: string): string {
return `"${value.replaceAll("\"", "\"\"")}"`;
}
function quoteQualifiedName(schemaName: string, objectName: string): string {
return `${quoteIdentifier(schemaName)}.${quoteIdentifier(objectName)}`;
}
function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const includeMigrationJournal = opts.includeMigrationJournal === true;
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
@@ -58,13 +155,35 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const lines: string[] = [];
const emit = (line: string) => lines.push(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
};
const emitStatementBoundary = () => {
emit(STATEMENT_BREAKPOINT);
};
emit("-- Paperclip database backup");
emit(`-- Created: ${new Date().toISOString()}`);
emit("");
emit("BEGIN;");
emitStatement("BEGIN;");
emitStatement("SET LOCAL session_replication_role = replica;");
emitStatement("SET LOCAL client_min_messages = warning;");
emit("");
const allTables = await sql<TableDefinition[]>`
SELECT table_schema AS schema_name, table_name AS tablename
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND (
table_schema = 'public'
OR (${includeMigrationJournal}::boolean AND table_schema = ${DRIZZLE_SCHEMA} AND table_name = ${DRIZZLE_MIGRATIONS_TABLE})
)
ORDER BY table_schema, table_name
`;
const tables = allTables;
const includedTableNames = new Set(tables.map(({ schema_name, tablename }) => tableKey(schema_name, tablename)));
// Get all enums
const enums = await sql<{ typname: string; labels: string[] }[]>`
SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS labels
@@ -78,23 +197,65 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
for (const e of enums) {
const labels = e.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(", ");
emit(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
emitStatement(`CREATE TYPE "public"."${e.typname}" AS ENUM (${labels});`);
}
if (enums.length > 0) emit("");
// Get tables in dependency order (referenced tables first)
const tables = await sql<{ tablename: string }[]>`
SELECT c.relname AS tablename
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND c.relname != '__drizzle_migrations'
ORDER BY c.relname
const allSequences = await sql<SequenceDefinition[]>`
SELECT
s.sequence_schema,
s.sequence_name,
s.data_type,
s.start_value,
s.minimum_value,
s.maximum_value,
s.increment,
s.cycle_option,
tblns.nspname AS owner_schema,
tbl.relname AS owner_table,
attr.attname AS owner_column
FROM information_schema.sequences s
JOIN pg_class seq ON seq.relname = s.sequence_name
JOIN pg_namespace n ON n.oid = seq.relnamespace AND n.nspname = s.sequence_schema
LEFT JOIN pg_depend dep ON dep.objid = seq.oid AND dep.deptype = 'a'
LEFT JOIN pg_class tbl ON tbl.oid = dep.refobjid
LEFT JOIN pg_namespace tblns ON tblns.oid = tbl.relnamespace
LEFT JOIN pg_attribute attr ON attr.attrelid = tbl.oid AND attr.attnum = dep.refobjsubid
WHERE s.sequence_schema = 'public'
OR (${includeMigrationJournal}::boolean AND s.sequence_schema = ${DRIZZLE_SCHEMA})
ORDER BY s.sequence_schema, s.sequence_name
`;
const sequences = allSequences.filter(
(seq) => !seq.owner_table || includedTableNames.has(tableKey(seq.owner_schema ?? "public", seq.owner_table)),
);
const schemas = new Set<string>();
for (const table of tables) schemas.add(table.schema_name);
for (const seq of sequences) schemas.add(seq.sequence_schema);
const extraSchemas = [...schemas].filter((schemaName) => schemaName !== "public");
if (extraSchemas.length > 0) {
emit("-- Schemas");
for (const schemaName of extraSchemas) {
emitStatement(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)};`);
}
emit("");
}
if (sequences.length > 0) {
emit("-- Sequences");
for (const seq of sequences) {
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
emitStatement(`DROP SEQUENCE IF EXISTS ${qualifiedSequenceName} CASCADE;`);
emitStatement(
`CREATE SEQUENCE ${qualifiedSequenceName} AS ${seq.data_type} INCREMENT BY ${seq.increment} MINVALUE ${seq.minimum_value} MAXVALUE ${seq.maximum_value} START WITH ${seq.start_value}${seq.cycle_option === "YES" ? " CYCLE" : " NO CYCLE"};`,
);
}
emit("");
}
// Get full CREATE TABLE DDL via column info
for (const { tablename } of tables) {
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const columns = await sql<{
column_name: string;
data_type: string;
@@ -108,12 +269,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
SELECT column_name, data_type, udt_name, is_nullable, column_default,
character_maximum_length, numeric_precision, numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename}
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
emit(`-- Table: ${tablename}`);
emit(`DROP TABLE IF EXISTS "${tablename}" CASCADE;`);
emit(`-- Table: ${schema_name}.${tablename}`);
emitStatement(`DROP TABLE IF EXISTS ${qualifiedTableName} CASCADE;`);
const colDefs: string[] = [];
for (const col of columns) {
@@ -149,7 +310,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND t.relname = ${tablename} AND c.contype = 'p'
WHERE n.nspname = ${schema_name} AND t.relname = ${tablename} AND c.contype = 'p'
GROUP BY c.conname
`;
for (const p of pk) {
@@ -157,17 +318,31 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
colDefs.push(` CONSTRAINT "${p.constraint_name}" PRIMARY KEY (${cols})`);
}
emit(`CREATE TABLE "${tablename}" (`);
emit(`CREATE TABLE ${qualifiedTableName} (`);
emit(colDefs.join(",\n"));
emit(");");
emitStatementBoundary();
emit("");
}
const ownedSequences = sequences.filter((seq) => seq.owner_table && seq.owner_column);
if (ownedSequences.length > 0) {
emit("-- Sequence ownership");
for (const seq of ownedSequences) {
emitStatement(
`ALTER SEQUENCE ${quoteQualifiedName(seq.sequence_schema, seq.sequence_name)} OWNED BY ${quoteQualifiedName(seq.owner_schema ?? "public", seq.owner_table!)}.${quoteIdentifier(seq.owner_column!)};`,
);
}
emit("");
}
// Foreign keys (after all tables created)
const fks = await sql<{
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
@@ -175,137 +350,157 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace n ON n.oid = src.relnamespace
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
WHERE c.contype = 'f' AND n.nspname = 'public'
GROUP BY c.conname, src.relname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY src.relname, c.conname
WHERE c.contype = 'f' AND (
srcn.nspname = 'public'
OR (${includeMigrationJournal}::boolean AND srcn.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emit(
`ALTER TABLE "${fk.source_table}" ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES "${fk.target_table}" (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Unique constraints
const uniques = await sql<{
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
tablename: string;
column_names: string[];
}[]>`
SELECT c.conname AS constraint_name,
n.nspname AS schema_name,
t.relname AS tablename,
array_agg(a.attname ORDER BY array_position(c.conkey, a.attnum)) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE n.nspname = 'public' AND c.contype = 'u'
GROUP BY c.conname, t.relname
ORDER BY t.relname, c.conname
WHERE c.contype = 'u' AND (
n.nspname = 'public'
OR (${includeMigrationJournal}::boolean AND n.nspname = ${DRIZZLE_SCHEMA})
)
GROUP BY c.conname, n.nspname, t.relname
ORDER BY n.nspname, t.relname, c.conname
`;
const uniques = allUniqueConstraints.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (uniques.length > 0) {
emit("-- Unique constraints");
for (const u of uniques) {
const cols = u.column_names.map((c) => `"${c}"`).join(", ");
emit(`ALTER TABLE "${u.tablename}" ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
emitStatement(`ALTER TABLE ${quoteQualifiedName(u.schema_name, u.tablename)} ADD CONSTRAINT "${u.constraint_name}" UNIQUE (${cols});`);
}
emit("");
}
// Indexes (non-primary, non-unique-constraint)
const indexes = await sql<{ indexdef: string }[]>`
SELECT indexdef
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname NOT IN (
SELECT conname FROM pg_constraint
WHERE connamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
WHERE (
schemaname = 'public'
OR (${includeMigrationJournal}::boolean AND schemaname = ${DRIZZLE_SCHEMA})
)
ORDER BY tablename, indexname
AND indexname NOT IN (
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = pg_indexes.schemaname
)
ORDER BY schemaname, tablename, indexname
`;
const indexes = allIndexes.filter((entry) => includedTableNames.has(tableKey(entry.schema_name, entry.tablename)));
if (indexes.length > 0) {
emit("-- Indexes");
for (const idx of indexes) {
emit(`${idx.indexdef};`);
emitStatement(`${idx.indexdef};`);
}
emit("");
}
// Dump data for each table
for (const { tablename } of tables) {
const count = await sql<{ n: number }[]>`
SELECT count(*)::int AS n FROM ${sql(tablename)}
`;
if ((count[0]?.n ?? 0) === 0) continue;
for (const { schema_name, tablename } of tables) {
const qualifiedTableName = quoteQualifiedName(schema_name, tablename);
const count = await sql.unsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM ${qualifiedTableName}`);
if (excludedTableNames.has(tablename) || (count[0]?.n ?? 0) === 0) continue;
// Get column info for this table
const cols = await sql<{ column_name: string; data_type: string }[]>`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${tablename}
WHERE table_schema = ${schema_name} AND table_name = ${tablename}
ORDER BY ordinal_position
`;
const colNames = cols.map((c) => `"${c.column_name}"`).join(", ");
emit(`-- Data for: ${tablename} (${count[0]!.n} rows)`);
emit(`-- Data for: ${schema_name}.${tablename} (${count[0]!.n} rows)`);
const rows = await sql`SELECT * FROM ${sql(tablename)}`.values();
const rows = await sql.unsafe(`SELECT * FROM ${qualifiedTableName}`).values();
const nullifiedColumns = nullifiedColumnsByTable.get(tablename) ?? new Set<string>();
for (const row of rows) {
const values = row.map((val: unknown) => {
const values = row.map((rawValue: unknown, index) => {
const columnName = cols[index]?.column_name;
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return `'${val.toISOString()}'`;
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'`;
return `'${String(val).replace(/'/g, "''")}'`;
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
});
emit(`INSERT INTO "${tablename}" (${colNames}) VALUES (${values.join(", ")});`);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
emit("");
}
// Sequence values
const sequences = await sql<{ sequence_name: string }[]>`
SELECT sequence_name
FROM information_schema.sequences
WHERE sequence_schema = 'public'
ORDER BY sequence_name
`;
if (sequences.length > 0) {
emit("-- Sequence values");
for (const seq of sequences) {
const val = await sql<{ last_value: string }[]>`
SELECT last_value::text FROM ${sql(seq.sequence_name)}
`;
if (val[0]) {
emit(`SELECT setval('"${seq.sequence_name}"', ${val[0].last_value});`);
const qualifiedSequenceName = quoteQualifiedName(seq.sequence_schema, seq.sequence_name);
const val = await sql.unsafe<{ last_value: string; is_called: boolean }[]>(
`SELECT last_value::text, is_called FROM ${qualifiedSequenceName}`,
);
const skipSequenceValue =
seq.owner_table !== null
&& excludedTableNames.has(seq.owner_table);
if (val[0] && !skipSequenceValue) {
emitStatement(`SELECT setval('${qualifiedSequenceName.replaceAll("'", "''")}', ${val[0].last_value}, ${val[0].is_called ? "true" : "false"});`);
}
}
emit("");
}
emit("COMMIT;");
emitStatement("COMMIT;");
emit("");
// Write the backup file
@@ -326,6 +521,36 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}
}
export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise<void> {
const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5));
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
await sql`SELECT 1`;
const contents = await readFile(opts.backupFile, "utf8");
const statements = contents
.split(STATEMENT_BREAKPOINT)
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
await sql.unsafe(statement).execute();
}
} catch (error) {
const statementPreview = typeof error === "object" && error !== null && typeof (error as Record<string, unknown>).query === "string"
? String((error as Record<string, unknown>).query)
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("--"))
: null;
throw new Error(
`Failed to restore ${basename(opts.backupFile)}: ${sanitizeRestoreErrorMessage(error)}${statementPreview ? ` [statement: ${statementPreview.slice(0, 120)}]` : ""}`,
);
} finally {
await sql.end();
}
}
export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string {
const size = formatBackupSize(result.sizeBytes);
const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : "";

View File

@@ -10,6 +10,10 @@ const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
function createUtilitySql(url: string) {
return postgres(url, { max: 1, onnotice: () => {} });
}
function isSafeIdentifier(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
@@ -223,7 +227,7 @@ async function applyPendingMigrationsManually(
journalEntries.map((entry) => [entry.fileName, normalizeFolderMillis(entry.folderMillis)]),
);
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
@@ -472,7 +476,7 @@ export async function reconcilePendingMigrationHistory(
return { repairedMigrations: [], remainingMigrations: [] };
}
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
const repairedMigrations: string[] = [];
try {
@@ -579,7 +583,7 @@ async function discoverMigrationTableSchema(sql: ReturnType<typeof postgres>): P
}
export async function inspectMigrations(url: string): Promise<MigrationState> {
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const availableMigrations = await listMigrationFiles();
@@ -642,7 +646,7 @@ export async function applyPendingMigrations(url: string): Promise<void> {
const initialState = await inspectMigrations(url);
if (initialState.status === "upToDate") return;
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const db = drizzlePg(sql);
@@ -680,7 +684,7 @@ export type MigrationBootstrapResult =
| { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number };
export async function migratePostgresIfEmpty(url: string): Promise<MigrationBootstrapResult> {
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const migrationTableSchema = await discoverMigrationTableSchema(sql);
@@ -719,7 +723,7 @@ export async function ensurePostgresDatabase(
throw new Error(`Unsafe database name: ${databaseName}`);
}
const sql = postgres(url, { max: 1 });
const sql = createUtilitySql(url);
try {
const existing = await sql<{ one: number }[]>`
select 1 as one from pg_database where datname = ${databaseName} limit 1

View File

@@ -12,8 +12,10 @@ export {
} from "./client.js";
export {
runDatabaseBackup,
runDatabaseRestore,
formatDatabaseBackupResult,
type RunDatabaseBackupOptions,
type RunDatabaseBackupResult,
type RunDatabaseRestoreOptions,
} from "./backup-lib.js";
export * from "./schema/index.js";

View File

@@ -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();

View 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);
}

View 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();

View File

@@ -0,0 +1,39 @@
CREATE TABLE "workspace_runtime_services" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid,
"project_workspace_id" uuid,
"issue_id" uuid,
"scope_type" text NOT NULL,
"scope_id" text,
"service_name" text NOT NULL,
"status" text NOT NULL,
"lifecycle" text NOT NULL,
"reuse_key" text,
"command" text,
"cwd" text,
"port" integer,
"url" text,
"provider" text NOT NULL,
"provider_ref" text,
"owner_agent_id" uuid,
"started_by_run_id" uuid,
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"stopped_at" timestamp with time zone,
"stop_policy" jsonb,
"health_status" text DEFAULT 'unknown' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint
CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,20 @@
"when": 1772807461603,
"tag": "0025_nasty_salo",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1773089625430,
"tag": "0026_lying_pete_wisdom",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveDatabaseTarget } from "./runtime-config.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function writeText(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value);
}
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("resolveDatabaseTarget", () => {
it("uses DATABASE_URL from process env first", () => {
process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip";
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip",
source: "DATABASE_URL",
});
});
it("uses DATABASE_URL from repo-local .paperclip/.env", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const projectDir = path.join(tempDir, "repo");
fs.mkdirSync(projectDir, { recursive: true });
process.chdir(projectDir);
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
});
writeText(
path.join(projectDir, ".paperclip", ".env"),
'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n',
);
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip",
source: "paperclip-env",
});
});
it("uses config postgres connection string when configured", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
source: "config.database.connectionString",
});
});
it("falls back to embedded postgres settings from config", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "~/paperclip-test-db",
embeddedPostgresPort: 55444,
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "embedded-postgres",
dataDir: path.resolve(os.homedir(), "paperclip-test-db"),
port: 55444,
source: "embedded-postgres@55444",
});
});
});

View 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,
};
}

View File

@@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { issues } from "./issues.js";

View File

@@ -40,6 +40,7 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
@@ -15,6 +15,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -0,0 +1,64 @@
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
import { projectWorkspaces } from "./project_workspaces.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const workspaceRuntimeServices = pgTable(
"workspace_runtime_services",
{
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
scopeType: text("scope_type").notNull(),
scopeId: text("scope_id"),
serviceName: text("service_name").notNull(),
status: text("status").notNull(),
lifecycle: text("lifecycle").notNull(),
reuseKey: text("reuse_key"),
command: text("command"),
cwd: text("cwd"),
port: integer("port"),
url: text("url"),
provider: text("provider").notNull(),
providerRef: text("provider_ref"),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
stopPolicy: jsonb("stop_policy").$type<Record<string, unknown>>(),
healthStatus: text("health_status").notNull().default("unknown"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on(
table.companyId,
table.projectWorkspaceId,
table.status,
),
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
table.companyId,
table.projectId,
table.status,
),
runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId),
companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on(
table.companyId,
table.updatedAt,
),
}),
);

View File

@@ -1,5 +1,12 @@
# @paperclipai/shared
## 0.3.0
### Minor Changes
- 6077ae6: Add support for Pi local adapter in constants and onboarding UI.
- Stable release preparation for 0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/shared",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts",

View File

@@ -77,6 +77,12 @@ export type {
Project,
ProjectGoalRef,
ProjectWorkspace,
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
Issue,
IssueAssigneeAdapterOverrides,
IssueComment,
@@ -156,9 +162,11 @@ export {
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,

View File

@@ -11,6 +11,14 @@ export type {
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
export type {
Issue,
IssueAssigneeAdapterOverrides,

View File

@@ -1,6 +1,7 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js";
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
export interface IssueAncestorProject {
id: string;
@@ -73,6 +74,7 @@ export interface Issue {
requestDepth: number;
billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
startedAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;

View File

@@ -1,4 +1,5 @@
import type { ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
export interface ProjectGoalRef {
id: string;
@@ -15,6 +16,7 @@ export interface ProjectWorkspace {
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date;
updatedAt: Date;
}
@@ -33,6 +35,7 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
archivedAt: Date | null;

View File

@@ -0,0 +1,58 @@
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
baseRef?: string | null;
branchTemplate?: string | null;
worktreeParentDir?: string | null;
provisionCommand?: string | null;
teardownCommand?: string | null;
}
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
defaultMode?: "project_primary" | "isolated";
allowIssueOverride?: boolean;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
branchPolicy?: Record<string, unknown> | null;
pullRequestPolicy?: Record<string, unknown> | null;
cleanupPolicy?: Record<string, unknown> | null;
}
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
export interface WorkspaceRuntimeService {
id: string;
companyId: string;
projectId: string | null;
projectWorkspaceId: string | null;
issueId: string | null;
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null;
serviceName: string;
status: "starting" | "running" | "stopped" | "failed";
lifecycle: "shared" | "ephemeral";
reuseKey: string | null;
command: string | null;
cwd: string | null;
port: number | null;
url: string | null;
provider: "local_process" | "adapter_managed";
providerRef: string | null;
ownerAgentId: string | null;
startedByRunId: string | null;
lastUsedAt: Date;
startedAt: Date;
stoppedAt: Date | null;
stopPolicy: Record<string, unknown> | null;
healthStatus: "unknown" | "healthy" | "unhealthy";
createdAt: Date;
updatedAt: Date;
}

View File

@@ -49,16 +49,19 @@ export {
updateProjectSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
type CreateProject,
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
type ProjectExecutionWorkspacePolicy,
} from "./project.js";
export {
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
@@ -66,6 +69,7 @@ export {
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
type IssueExecutionWorkspaceSettings,
type CheckoutIssue,
type AddIssueComment,
type LinkIssueApproval,

View File

@@ -1,6 +1,25 @@
import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
})
.strict();
export const issueAssigneeAdapterOverridesSchema = z
.object({
adapterConfig: z.record(z.unknown()).optional(),
@@ -21,6 +40,7 @@ export const createIssueSchema = z.object({
requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
});
@@ -39,6 +59,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
});
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
export const checkoutIssueSchema = z.object({
agentId: z.string().uuid(),

View File

@@ -1,6 +1,30 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(),
})
.strict();
export const projectExecutionWorkspacePolicySchema = z
.object({
enabled: z.boolean(),
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
allowIssueOverride: z.boolean().optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(),
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
})
.strict();
const projectWorkspaceFields = {
name: z.string().min(1).optional(),
cwd: z.string().min(1).optional().nullable(),
@@ -43,6 +67,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
};
@@ -56,3 +81,5 @@ export type CreateProject = z.infer<typeof createProjectSchema>;
export const updateProjectSchema = z.object(projectFields).partial();
export type UpdateProject = z.infer<typeof updateProjectSchema>;
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;

View File

@@ -19,6 +19,7 @@ Examples:
Notes:
- Run this after pushing the stable release branch and tag.
- Defaults to git remote public-gh.
- If the release already exists, this script updates its title and notes.
EOF
}
@@ -53,13 +54,19 @@ fi
tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
PUBLISH_REMOTE="$(resolve_release_remote)"
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required to create GitHub releases." >&2
exit 1
fi
GITHUB_REPO="$(github_repo_from_remote "$PUBLISH_REMOTE" || true)"
if [ -z "$GITHUB_REPO" ]; then
echo "Error: could not determine GitHub repository from remote $PUBLISH_REMOTE." >&2
exit 1
fi
if [ ! -f "$notes_file" ]; then
echo "Error: release notes file not found at $notes_file." >&2
exit 1
@@ -71,7 +78,7 @@ if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then
fi
if [ "$dry_run" = true ]; then
echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file"
echo "[dry-run] gh release create $tag -R $GITHUB_REPO --title $tag --notes-file $notes_file"
exit 0
fi
@@ -80,10 +87,10 @@ if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/ta
exit 1
fi
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --title "$tag" --notes-file "$notes_file"
if gh release view "$tag" -R "$GITHUB_REPO" >/dev/null 2>&1; then
gh release edit "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Updated GitHub Release $tag"
else
gh release create "$tag" --title "$tag" --notes-file "$notes_file"
gh release create "$tag" -R "$GITHUB_REPO" --title "$tag" --notes-file "$notes_file"
echo "Created GitHub Release $tag"
fi

View File

@@ -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);
});

View File

@@ -49,20 +49,27 @@ wait_for_http() {
generate_bootstrap_invite_url() {
local bootstrap_output
bootstrap_output="$(
local bootstrap_status
if bootstrap_output="$(
docker exec \
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
-e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \
-e PAPERCLIP_HOME="/paperclip" \
"$CONTAINER_NAME" bash -lc \
'npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \
'timeout 20s npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \
2>&1
)" || {
)"; then
bootstrap_status=0
else
bootstrap_status=$?
fi
if [[ $bootstrap_status -ne 0 && $bootstrap_status -ne 124 ]]; then
echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2
printf '%s\n' "$bootstrap_output" >&2
return 1
}
fi
local invite_url
invite_url="$(
@@ -77,6 +84,10 @@ generate_bootstrap_invite_url() {
return 1
fi
if [[ $bootstrap_status -eq 124 ]]; then
echo " Smoke bootstrap: bootstrap-ceo timed out after printing invite URL; continuing" >&2
fi
printf '%s\n' "$invite_url"
}

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
if [[ ! -d "$base_cwd" ]]; then
echo "Base workspace does not exist: $base_cwd" >&2
exit 1
fi
if [[ ! -d "$worktree_cwd" ]]; then
echo "Derived worktree does not exist: $worktree_cwd" >&2
exit 1
fi
while IFS= read -r relative_path; do
[[ -n "$relative_path" ]] || continue
source_path="$base_cwd/$relative_path"
target_path="$worktree_cwd/$relative_path"
[[ -d "$source_path" ]] || continue
[[ -e "$target_path" || -L "$target_path" ]] && continue
mkdir -p "$(dirname "$target_path")"
ln -s "$source_path" "$target_path"
done < <(
cd "$base_cwd" &&
find . \
-mindepth 1 \
-maxdepth 3 \
-type d \
-name node_modules \
! -path './.git/*' \
! -path './.paperclip/*' \
| sed 's#^\./##'
)

View File

@@ -21,6 +21,35 @@ git_remote_exists() {
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
}
github_repo_from_remote() {
local remote_url
remote_url="$(git -C "$REPO_ROOT" remote get-url "$1" 2>/dev/null || true)"
[ -n "$remote_url" ] || return 1
remote_url="${remote_url%.git}"
remote_url="${remote_url#ssh://}"
node - "$remote_url" <<'NODE'
const remoteUrl = process.argv[2];
const patterns = [
/^https?:\/\/github\.com\/([^/]+\/[^/]+)$/,
/^git@github\.com:([^/]+\/[^/]+)$/,
/^[^:]+:([^/]+\/[^/]+)$/
];
for (const pattern of patterns) {
const match = remoteUrl.match(pattern);
if (!match) continue;
process.stdout.write(match[1]);
process.exit(0);
}
process.exit(1);
NODE
}
resolve_release_remote() {
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"

View File

@@ -1,5 +1,25 @@
# @paperclipai/server
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
## 0.2.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@paperclipai/server",
"version": "0.2.7",
"version": "0.3.0",
"type": "module",
"exports": {
".": "./src/index.ts"

View File

@@ -0,0 +1,110 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalRoutes } from "../routes/approvals.js";
import { errorHandler } from "../middleware/index.js";
const mockApprovalService = vi.hoisted(() => ({
list: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
approve: vi.fn(),
reject: vi.fn(),
requestRevision: vi.fn(),
resubmit: vi.fn(),
listComments: vi.fn(),
addComment: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
listIssuesForApproval: vi.fn(),
linkManyForApproval: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeHireApprovalPayloadForPersistence: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
logActivity: mockLogActivity,
secretService: () => mockSecretService,
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
};
next();
});
app.use("/api", approvalRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("approval routes idempotent retries", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
});
it("does not emit duplicate approval side effects when approve is already resolved", async () => {
mockApprovalService.approve.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "approved",
payload: {},
requestedByAgentId: "agent-1",
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/approve")
.send({});
expect(res.status).toBe(200);
expect(mockIssueApprovalService.listIssuesForApproval).not.toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("does not emit duplicate rejection logs when reject is already resolved", async () => {
mockApprovalService.reject.mockResolvedValue({
approval: {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "rejected",
payload: {},
},
applied: false,
});
const res = await request(createApp())
.post("/api/approvals/approval-1/reject")
.send({});
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalService } from "../services/approvals.ts";
const mockAgentService = vi.hoisted(() => ({
activatePendingApproval: vi.fn(),
create: vi.fn(),
terminate: vi.fn(),
}));
const mockNotifyHireApproved = vi.hoisted(() => vi.fn());
vi.mock("../services/agents.js", () => ({
agentService: vi.fn(() => mockAgentService),
}));
vi.mock("../services/hire-hook.js", () => ({
notifyHireApproved: mockNotifyHireApproved,
}));
type ApprovalRecord = {
id: string;
companyId: string;
type: string;
status: string;
payload: Record<string, unknown>;
requestedByAgentId: string | null;
};
function createApproval(status: string): ApprovalRecord {
return {
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status,
payload: { agentId: "agent-1" },
requestedByAgentId: "requester-1",
};
}
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
const pendingSelectResults = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
const from = vi.fn(() => ({ where: selectWhere }));
const select = vi.fn(() => ({ from }));
const returning = vi.fn(async () => updateResults);
const updateWhere = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));
return {
db: { select, update },
selectWhere,
returning,
};
}
describe("approvalService resolution idempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.activatePendingApproval.mockResolvedValue(undefined);
mockAgentService.create.mockResolvedValue({ id: "agent-1" });
mockAgentService.terminate.mockResolvedValue(undefined);
mockNotifyHireApproved.mockResolvedValue(undefined);
});
it("treats repeated approve retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("approved")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("approved");
expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled();
expect(mockNotifyHireApproved).not.toHaveBeenCalled();
});
it("treats repeated reject retries as no-ops after another worker resolves the approval", async () => {
const dbStub = createDbStub(
[[createApproval("pending")], [createApproval("rejected")]],
[],
);
const svc = approvalService(dbStub.db as any);
const result = await svc.reject("approval-1", "board", "not now");
expect(result.applied).toBe(false);
expect(result.approval.status).toBe("rejected");
expect(mockAgentService.terminate).not.toHaveBeenCalled();
});
it("still performs side effects when the resolution update is newly applied", async () => {
const approved = createApproval("approved");
const dbStub = createDbStub([[createApproval("pending")]], [approved]);
const svc = approvalService(dbStub.db as any);
const result = await svc.approve("approval-1", "board", "ship it");
expect(result.applied).toBe(true);
expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith("agent-1");
expect(mockNotifyHireApproved).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
parseAllowedTypes,
matchesContentType,
DEFAULT_ALLOWED_TYPES,
} from "../attachment-types.js";
describe("parseAllowedTypes", () => {
it("returns default image types when input is undefined", () => {
expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("returns default image types when input is empty string", () => {
expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]);
});
it("parses comma-separated types", () => {
expect(parseAllowedTypes("image/*,application/pdf")).toEqual([
"image/*",
"application/pdf",
]);
});
it("trims whitespace", () => {
expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([
"image/png",
"application/pdf",
]);
});
it("lowercases entries", () => {
expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]);
});
it("filters empty segments", () => {
expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([
"image/png",
"application/pdf",
]);
});
});
describe("matchesContentType", () => {
it("matches exact types", () => {
const patterns = ["application/pdf", "image/png"];
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(false);
});
it("matches /* wildcard patterns", () => {
const patterns = ["image/*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("image/jpeg", patterns)).toBe(true);
expect(matchesContentType("image/svg+xml", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("matches .* wildcard patterns", () => {
const patterns = ["application/vnd.openxmlformats-officedocument.*"];
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
patterns,
),
).toBe(true);
expect(
matchesContentType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
patterns,
),
).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(false);
});
it("is case-insensitive", () => {
const patterns = ["application/pdf"];
expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true);
expect(matchesContentType("Application/Pdf", patterns)).toBe(true);
});
it("combines exact and wildcard patterns", () => {
const patterns = ["image/*", "application/pdf", "text/*"];
expect(matchesContentType("image/webp", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/csv", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(false);
});
it("handles plain * as allow-all wildcard", () => {
const patterns = ["*"];
expect(matchesContentType("image/png", patterns)).toBe(true);
expect(matchesContentType("application/pdf", patterns)).toBe(true);
expect(matchesContentType("text/plain", patterns)).toBe(true);
expect(matchesContentType("application/zip", patterns)).toBe(true);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "../services/execution-workspace-policy.ts";
describe("execution workspace policy helpers", () => {
it("defaults new issue settings from enabled project policy", () => {
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "isolated",
}),
).toEqual({ mode: "isolated" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
}),
).toEqual({ mode: "project_primary" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
expect(
resolveExecutionWorkspaceMode({
projectPolicy: null,
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("agent_default");
});
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
const result = buildExecutionWorkspaceAdapterConfig({
agentConfig: {
workspaceStrategy: { type: "project_primary" },
},
projectPolicy: {
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
issueSettings: null,
mode: "isolated",
legacyUseProjectWorkspace: null,
});
expect(result.workspaceStrategy).toEqual({
type: "git_worktree",
baseRef: "origin/main",
provisionCommand: "bash ./scripts/provision-worktree.sh",
});
expect(result.workspaceRuntime).toEqual({
services: [{ name: "web", command: "pnpm dev" }],
});
});
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
const baseConfig = {
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
workspaceRuntime: { services: [{ name: "web" }] },
};
expect(
buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
legacyUseProjectWorkspace: null,
}).workspaceStrategy,
).toBeUndefined();
const agentDefault = buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: null,
issueSettings: { mode: "agent_default" },
mode: "agent_default",
legacyUseProjectWorkspace: null,
});
expect(agentDefault.workspaceStrategy).toBeUndefined();
expect(agentDefault.workspaceRuntime).toBeUndefined();
});
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
expect(
parseProjectExecutionWorkspacePolicy({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
}),
).toEqual({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
});
expect(
parseIssueExecutionWorkspaceSettings({
mode: "project_primary",
}),
).toEqual({
mode: "project_primary",
});
});
});

View File

@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import {
buildOpenClawGatewayConfig,
parseOpenClawGatewayStdoutLine,
} from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext(
@@ -36,7 +39,9 @@ function buildContext(
};
}
async function createMockGatewayServer() {
async function createMockGatewayServer(options?: {
waitPayload?: Record<string, unknown>;
}) {
const server = createServer();
const wss = new WebSocketServer({ server });
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
type: "res",
id: frame.id,
ok: true,
payload: {
payload: options?.waitPayload ?? {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
paperclipWorkspace: {
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
branchName: "pap-123-test",
},
paperclipWorkspaces: [
{
id: "workspace-1",
cwd: "/tmp/project",
},
],
paperclipRuntimeServiceIntents: [
{
name: "preview",
lifecycle: "ephemeral",
},
],
},
},
),
);
@@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => {
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(payload?.paperclip).toEqual(
expect.objectContaining({
runId: "run-123",
companyId: "company-123",
agentId: "agent-123",
taskId: "task-123",
issueId: "issue-123",
workspace: expect.objectContaining({
cwd: "/tmp/worktrees/pap-123",
strategy: "git_worktree",
}),
workspaces: [
expect.objectContaining({
id: "workspace-1",
cwd: "/tmp/project",
}),
],
workspaceRuntime: expect.objectContaining({
services: [
expect.objectContaining({
name: "preview",
lifecycle: "ephemeral",
}),
],
}),
}),
);
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
});
it("returns adapter-managed runtime services from gateway result meta", async () => {
const gateway = await createMockGatewayServer({
waitPayload: {
runId: "run-123",
status: "ok",
startedAt: 1,
endedAt: 2,
meta: {
runtimeServices: [
{
name: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
},
],
},
},
});
try {
const result = await execute(
buildContext({
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
waitTimeoutMs: 2000,
}),
);
expect(result.exitCode).toBe(0);
expect(result.runtimeServices).toEqual([
expect.objectContaining({
serviceName: "preview",
scopeType: "run",
url: "https://preview.example/run-123",
providerRef: "sandbox-123",
lifecycle: "ephemeral",
status: "running",
}),
]);
} finally {
await gateway.close();
}
});
it("auto-approves pairing once and retries the run", async () => {
const gateway = await createMockGatewayServerWithPairing();
const logs: string[] = [];
@@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => {
});
});
describe("openclaw gateway ui build config", () => {
it("parses payload template and runtime services json", () => {
const config = buildOpenClawGatewayConfig({
adapterType: "openclaw_gateway",
cwd: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "wss://gateway.example/ws",
payloadTemplateJson: JSON.stringify({
agentId: "remote-agent-123",
metadata: { team: "platform" },
}),
runtimeServicesJson: JSON.stringify({
services: [
{
name: "preview",
lifecycle: "shared",
},
],
}),
bootstrapPrompt: "",
maxTurnsPerRun: 0,
heartbeatEnabled: true,
intervalSec: 300,
});
expect(config).toEqual(
expect.objectContaining({
url: "wss://gateway.example/ws",
payloadTemplate: {
agentId: "remote-agent-123",
metadata: { team: "platform" },
},
workspaceRuntime: {
services: [
{
name: "preview",
lifecycle: "shared",
},
],
},
}),
);
});
});
describe("openclaw gateway testEnvironment", () => {
it("reports missing url as failure", async () => {
const result = await testEnvironment({

View File

@@ -0,0 +1,386 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
ensureRuntimeServicesForRun,
normalizeAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts";
const execFileAsync = promisify(execFile);
const leasedRunIds = new Set<string>();
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
return repoRoot;
}
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
return {
baseCwd: cwd,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
strategy: "project_primary",
cwd,
branchName: null,
worktreePath: null,
warnings: [],
created: false,
};
}
afterEach(async () => {
await Promise.all(
Array.from(leasedRunIds).map(async (runId) => {
await releaseRuntimeServicesForRun(runId);
leasedRunIds.delete(runId);
}),
);
});
describe("realizeExecutionWorkspace", () => {
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
const repoRoot = await createTempRepo();
const first = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(first.strategy).toBe("git_worktree");
expect(first.created).toBe(true);
expect(first.branchName).toBe("PAP-447-add-worktree-support");
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
const second = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
expect(second.created).toBe(false);
expect(second.cwd).toBe(first.cwd);
expect(second.branchName).toBe(first.branchName);
});
it("runs a configured provision command inside the derived worktree", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
await fs.writeFile(
path.join(repoRoot, "scripts", "provision.sh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-provision-branch",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BASE_CWD\" > .paperclip-provision-base",
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_CREATED\" > .paperclip-provision-created",
].join("\n"),
"utf8",
);
await runGit(repoRoot, ["add", "scripts/provision.sh"]);
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
const workspace = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-branch"), "utf8")).resolves.toBe(
"PAP-448-run-provision-command\n",
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-base"), "utf8")).resolves.toBe(
`${repoRoot}\n`,
);
await expect(fs.readFile(path.join(workspace.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe(
"true\n",
);
const reused = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision.sh",
},
},
issue: {
id: "issue-1",
identifier: "PAP-448",
title: "Run provision command",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
});
});
describe("ensureRuntimeServicesForRun", () => {
it("reuses shared runtime services across runs and starts a new service after release", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
const workspace = buildWorkspace(workspaceRoot);
const serviceCommand =
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command: serviceCommand,
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "project_workspace",
stopPolicy: {
type: "on_run_finish",
},
},
],
},
};
const run1 = "run-1";
const run2 = "run-2";
leasedRunIds.add(run1);
leasedRunIds.add(run2);
const first = await ensureRuntimeServicesForRun({
runId: run1,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.reused).toBe(false);
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
const response = await fetch(first[0]!.url!);
expect(await response.text()).toBe("ok");
const second = await ensureRuntimeServicesForRun({
runId: run2,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.reused).toBe(true);
expect(second[0]?.id).toBe(first[0]?.id);
await releaseRuntimeServicesForRun(run1);
leasedRunIds.delete(run1);
await releaseRuntimeServicesForRun(run2);
leasedRunIds.delete(run2);
const run3 = "run-3";
leasedRunIds.add(run3);
const third = await ensureRuntimeServicesForRun({
runId: run3,
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
issue: null,
workspace,
config,
adapterEnv: {},
});
expect(third).toHaveLength(1);
expect(third[0]?.reused).toBe(false);
expect(third[0]?.id).not.toBe(first[0]?.id);
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
const workspace = buildWorkspace("/tmp/project");
const now = new Date("2026-03-09T12:00:00.000Z");
const first = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
const second = normalizeAdapterManagedRuntimeServices({
adapterType: "openclaw_gateway",
runId: "run-1",
agent: {
id: "agent-1",
name: "Gateway Agent",
companyId: "company-1",
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Worktree support",
},
workspace,
reports: [
{
serviceName: "preview",
url: "https://preview.example/run-1",
providerRef: "sandbox-123",
scopeType: "run",
},
],
now,
});
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "workspace-1",
issueId: "issue-1",
serviceName: "preview",
provider: "adapter_managed",
status: "running",
healthStatus: "healthy",
startedByRunId: "run-1",
});
expect(first[0]?.id).toBe(second[0]?.id);
});
});

View File

@@ -32,6 +32,7 @@ export async function createApp(
db: Db,
opts: {
uiMode: UiMode;
serverPort: number;
storageService: StorageService;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
@@ -146,12 +147,18 @@ export async function createApp(
if (opts.uiMode === "vite-dev") {
const uiRoot = path.resolve(__dirname, "../../ui");
const hmrPort = opts.serverPort + 10000;
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,
appType: "spa",
server: {
middlewareMode: true,
hmr: {
host: opts.bindHost,
port: hmrPort,
clientPort: hmrPort,
},
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
},
});

View File

@@ -0,0 +1,68 @@
/**
* Shared attachment content-type configuration.
*
* By default only image types are allowed. Set the
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
* comma-separated list of MIME types or wildcard patterns to expand the
* allowed set.
*
* Examples:
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
*
* Supported pattern syntax:
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
];
/**
* Parse a comma-separated list of MIME type patterns into a normalised array.
* Returns the default image-only list when the input is empty or undefined.
*/
export function parseAllowedTypes(raw: string | undefined): string[] {
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
const parsed = raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0);
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
}
/**
* Check whether `contentType` matches any entry in `allowedPatterns`.
*
* Supports exact matches ("application/pdf") and wildcard / prefix
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
*/
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
const ct = contentType.toLowerCase();
return allowedPatterns.some((pattern) => {
if (pattern === "*") return true;
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
return ct.startsWith(pattern.slice(0, -1));
}
return ct === pattern;
});
}
// ---------- Module-level singletons read once at startup ----------
const allowedPatterns: string[] = parseAllowedTypes(
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
);
/** Convenience wrapper using the process-level allowed list. */
export function isAllowedContentType(contentType: string): boolean {
return matchesContentType(contentType, allowedPatterns);
}
export const MAX_ATTACHMENT_BYTES =
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;

View File

@@ -25,7 +25,7 @@ import { createApp } from "./app.js";
import { loadConfig } from "./config.js";
import { logger } from "./middleware/logger.js";
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
import { heartbeatService } from "./services/index.js";
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
@@ -460,10 +460,12 @@ export async function startServer(): Promise<StartedServer> {
authReady = true;
}
const listenPort = await detectPort(config.port);
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, {
uiMode,
serverPort: listenPort,
storageService,
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
@@ -475,7 +477,6 @@ export async function startServer(): Promise<StartedServer> {
resolveSession,
});
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
const listenPort = await detectPort(config.port);
if (listenPort !== config.port) {
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
@@ -494,6 +495,19 @@ export async function startServer(): Promise<StartedServer> {
deploymentMode: config.deploymentMode,
resolveSessionFromHeaders,
});
void reconcilePersistedRuntimeServicesOnStartup(db as any)
.then((result) => {
if (result.reconciled > 0) {
logger.warn(
{ reconciled: result.reconciled },
"reconciled persisted runtime services from a previous server process",
);
}
})
.catch((err) => {
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
});
if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any);
@@ -502,7 +516,7 @@ export async function startServer(): Promise<StartedServer> {
void heartbeat.reapOrphanedRuns().catch((err) => {
logger.error({ err }, "startup reap of orphaned heartbeat runs failed");
});
setInterval(() => {
void heartbeat
.tickTimers(new Date())

View File

@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
if (applied) {
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
const primaryIssueId = linkedIssueIds[0] ?? null;
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: {
type: approval.type,
requestedByAgentId: approval.requestedByAgentId,
linkedIssueIds,
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
if (approval.requestedByAgentId) {
try {
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
source: "automation",
triggerDetail: "system",
reason: "approval_approved",
payload: {
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
},
requestedByActorType: "user",
requestedByActorId: req.actor.userId ?? "board",
contextSnapshot: {
source: "approval.approved",
approvalId: approval.id,
approvalStatus: approval.status,
issueId: primaryIssueId,
issueIds: linkedIssueIds,
taskId: primaryIssueId,
wakeReason: "approval_approved",
},
});
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_queued",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
wakeRunId: wakeRun?.id ?? null,
linkedIssueIds,
},
});
} catch (err) {
logger.warn(
{
err,
approvalId: approval.id,
requestedByAgentId: approval.requestedByAgentId,
},
"failed to queue requester wakeup after approval",
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.requester_wakeup_failed",
entityType: "approval",
entityId: approval.id,
details: {
requesterAgentId: approval.requestedByAgentId,
linkedIssueIds,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
}
@@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) {
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
const { approval, applied } = await svc.reject(
id,
req.body.decidedByUserId ?? "board",
req.body.decisionNote,
);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
if (applied) {
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
}
res.json(redactApprovalPayload(approval));
});

View File

@@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
import { assetService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function assetRoutes(db: Db, storage: StorageService) {
const router = Router();
const svc = assetService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
});
async function runSingleFileUpload(req: Request, res: Response) {
@@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
} catch (err) {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
return;
}
res.status(400).json({ error: err.message });
@@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) {
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
return;
}
if (file.buffer.length <= 0) {

View File

@@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
export function issueRoutes(db: Db, storage: StorageService) {
const router = Router();
@@ -230,6 +222,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
touchedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
labelId: req.query.labelId as string | undefined,
q: req.query.q as string | undefined,
});
@@ -1067,7 +1060,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
const contentType = (file.mimetype || "").toLowerCase();
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
return;
}

View File

@@ -1,4 +1,4 @@
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
@@ -8,6 +8,9 @@ import { notifyHireApproved } from "./hire-hook.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
async function getExistingApproval(id: string) {
const existing = await db
@@ -19,6 +22,50 @@ export function approvalService(db: Db) {
return existing;
}
async function resolveApproval(
id: string,
targetStatus: "approved" | "rejected",
decidedByUserId: string,
decisionNote: string | null | undefined,
): Promise<ResolutionResult> {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
if (existing.status === targetStatus) {
return { approval: existing, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: targetStatus,
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
if (updated) {
return { approval: updated, applied: true };
}
const latest = await getExistingApproval(id);
if (latest.status === targetStatus) {
return { approval: latest, applied: false };
}
throw unprocessable(
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
);
}
return {
list: (companyId: string, status?: string) => {
const conditions = [eq(approvals.companyId, companyId)];
@@ -41,27 +88,16 @@ export function approvalService(db: Db) {
.then((rows) => rows[0]),
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be approved");
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "approved",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
const { approval: updated, applied } = await resolveApproval(
id,
"approved",
decidedByUserId,
decisionNote,
);
let hireApprovedAgentId: string | null = null;
if (updated.type === "hire_agent") {
const now = new Date();
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -103,30 +139,18 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await getExistingApproval(id);
if (!canResolveStatuses.has(existing.status)) {
throw unprocessable("Only pending or revision requested approvals can be rejected");
}
const { approval: updated, applied } = await resolveApproval(
id,
"rejected",
decidedByUserId,
decisionNote,
);
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "rejected",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
if (updated.type === "hire_agent") {
if (applied && updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
if (payloadAgentId) {
@@ -134,7 +158,7 @@ export function approvalService(db: Db) {
}
}
return updated;
return { approval: updated, applied };
},
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {

View File

@@ -0,0 +1,143 @@
import type {
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
IssueExecutionWorkspaceSettings,
ProjectExecutionWorkspacePolicy,
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
return { ...value };
}
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
const parsed = parseObject(raw);
const type = asString(parsed.type, "");
if (type !== "project_primary" && type !== "git_worktree") {
return null;
}
return {
type,
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
};
}
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
const defaultMode = asString(parsed.defaultMode, "");
const allowIssueOverride =
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
return {
enabled,
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
: {}),
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
: {}),
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
: {}),
};
}
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const mode = asString(parsed.mode, "");
return {
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
? { mode }
: {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
};
}
export function defaultIssueExecutionWorkspaceSettingsForProject(
projectPolicy: ProjectExecutionWorkspacePolicy | null,
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
return {
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
};
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
legacyUseProjectWorkspace: boolean | null;
}): ParsedExecutionWorkspaceMode {
const issueMode = input.issueSettings?.mode;
if (issueMode && issueMode !== "inherit") {
return issueMode;
}
if (input.projectPolicy?.enabled) {
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
}
if (input.legacyUseProjectWorkspace === false) {
return "agent_default";
}
return "project_primary";
}
export function buildExecutionWorkspaceAdapterConfig(input: {
agentConfig: Record<string, unknown>;
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
mode: ParsedExecutionWorkspaceMode;
legacyUseProjectWorkspace: boolean | null;
}): Record<string, unknown> {
const nextConfig = { ...input.agentConfig };
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
const issueHasWorkspaceOverrides = Boolean(
input.issueSettings?.mode ||
input.issueSettings?.workspaceStrategy ||
input.issueSettings?.workspaceRuntime,
);
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
if (hasWorkspaceControl) {
if (input.mode === "isolated") {
const strategy =
input.issueSettings?.workspaceStrategy ??
input.projectPolicy?.workspaceStrategy ??
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
} else {
delete nextConfig.workspaceStrategy;
}
if (input.mode === "agent_default") {
delete nextConfig.workspaceRuntime;
} else if (input.issueSettings?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
} else if (input.projectPolicy?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
}
}
return nextConfig;
}

View File

@@ -11,6 +11,7 @@ import {
heartbeatRuns,
costEvents,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import { conflict, notFound } from "../errors.js";
@@ -23,6 +24,20 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import {
buildWorkspaceReadyComment,
ensureRuntimeServicesForRun,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import {
buildExecutionWorkspaceAdapterConfig,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -406,6 +421,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
async function getAgent(agentId: string) {
return db
@@ -1071,8 +1087,10 @@ export function heartbeatService(db: Db) {
const issueAssigneeConfig = issueId
? await db
.select({
projectId: issues.projectId,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
@@ -1084,6 +1102,18 @@ export function heartbeatService(db: Db) {
issueAssigneeConfig.assigneeAdapterOverrides,
)
: null;
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
issueAssigneeConfig?.executionWorkspaceSettings,
);
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
: null;
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
@@ -1093,20 +1123,72 @@ export function heartbeatService(db: Db) {
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
);
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const resolvedWorkspace = await resolveWorkspaceForRun(
agent,
context,
previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
{ useProjectWorkspace: executionWorkspaceMode !== "agent_default" },
);
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
mode: executionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
: workspaceManagedConfig;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
);
const issueRef = issueId
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const executionWorkspace = await realizeExecutionWorkspace({
base: {
baseCwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: resolvedConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
});
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
previousSessionParams,
resolvedWorkspace,
resolvedWorkspace: {
...resolvedWorkspace,
cwd: executionWorkspace.cwd,
},
});
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
const runtimeWorkspaceWarnings = [
...resolvedWorkspace.warnings,
...executionWorkspace.warnings,
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
...(resetTaskSession && sessionResetReason
? [
@@ -1117,16 +1199,33 @@ export function heartbeatService(db: Db) {
: []),
];
context.paperclipWorkspace = {
cwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
cwd: executionWorkspace.cwd,
source: executionWorkspace.source,
mode: executionWorkspaceMode,
strategy: executionWorkspace.strategy,
projectId: executionWorkspace.projectId,
workspaceId: executionWorkspace.workspaceId,
repoUrl: executionWorkspace.repoUrl,
repoRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
worktreePath: executionWorkspace.worktreePath,
};
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = resolvedWorkspace.projectId;
const runtimeServiceIntents = (() => {
const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime);
return Array.isArray(runtimeConfig.services)
? runtimeConfig.services.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
})();
if (runtimeServiceIntents.length > 0) {
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
} else {
delete context.paperclipRuntimeServiceIntents;
}
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
context.projectId = executionWorkspace.projectId;
}
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
const previousSessionDisplayId = truncateDisplayId(
@@ -1146,7 +1245,6 @@ export function heartbeatService(db: Db) {
let handle: RunLogHandle | null = null;
let stdoutExcerpt = "";
let stderrExcerpt = "";
try {
const startedAt = run.startedAt ?? new Date();
const runningWithSession = await db
@@ -1154,6 +1252,7 @@ export function heartbeatService(db: Db) {
.set({
startedAt,
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id))
@@ -1235,15 +1334,54 @@ export function heartbeatService(db: Db) {
for (const warning of runtimeWorkspaceWarnings) {
await onLog("stderr", `[paperclip] ${warning}\n`);
}
const config = parseObject(agent.adapterConfig);
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
const adapterEnv = Object.fromEntries(
Object.entries(parseObject(resolvedConfig.env)).filter(
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
),
);
const runtimeServices = await ensureRuntimeServicesForRun({
db,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
config: resolvedConfig,
adapterEnv,
onLog,
});
if (runtimeServices.length > 0) {
context.paperclipRuntimeServices = runtimeServices;
context.paperclipRuntimePrimaryUrl =
runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
}
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
if (meta.env && secretKeys.size > 0) {
for (const key of secretKeys) {
@@ -1284,6 +1422,54 @@ export function heartbeatService(db: Db) {
onMeta: onAdapterMeta,
authToken: authToken ?? undefined,
});
const adapterManagedRuntimeServices = adapterResult.runtimeServices
? await persistAdapterManagedRuntimeServices({
db,
adapterType: agent.adapterType,
runId: run.id,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
issue: issueRef,
workspace: executionWorkspace,
reports: adapterResult.runtimeServices,
})
: [];
if (adapterManagedRuntimeServices.length > 0) {
const combinedRuntimeServices = [
...runtimeServices,
...adapterManagedRuntimeServices,
];
context.paperclipRuntimeServices = combinedRuntimeServices;
context.paperclipRuntimePrimaryUrl =
combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
await db
.update(heartbeatRuns)
.set({
contextSnapshot: context,
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
if (issueId) {
try {
await issuesSvc.addComment(
issueId,
buildWorkspaceReadyComment({
workspace: executionWorkspace,
runtimeServices: adapterManagedRuntimeServices,
}),
{ agentId: agent.id },
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
const nextSessionState = resolveNextSessionState({
codec: sessionCodec,
adapterResult,
@@ -1460,6 +1646,7 @@ export function heartbeatService(db: Db) {
await finalizeAgentStatus(agent.id, "failed");
} finally {
await releaseRuntimeServicesForRun(run.id);
await startNextQueuedRunForAgent(agent.id);
}
}

View File

@@ -17,4 +17,5 @@ export { companyPortabilityService } from "./company-portability.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";

View File

@@ -18,6 +18,10 @@ import {
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -53,6 +57,7 @@ export interface IssueFilters {
touchedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
labelId?: string;
q?: string;
}
@@ -458,6 +463,7 @@ export function issueService(db: Db) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
@@ -635,6 +641,19 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
const project = await tx
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
executionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
) as Record<string, unknown> | null;
}
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
@@ -644,7 +663,13 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
const values = {
...issueData,
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
identifier,
} as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}

View File

@@ -1,17 +1,22 @@
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclipai/db";
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import {
PROJECT_COLORS,
deriveProjectUrlKey,
isUuidLike,
normalizeProjectUrlKey,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
type ProjectWorkspace,
type WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
type CreateWorkspaceInput = {
name?: string | null;
@@ -23,10 +28,11 @@ type CreateWorkspaceInput = {
};
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
interface ProjectWithGoals extends ProjectRow {
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
urlKey: string;
goalIds: string[];
goals: ProjectGoalRef[];
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
}
@@ -74,11 +80,46 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
urlKey: deriveProjectUrlKey(r.name, r.id),
goalIds: g.map((x) => x.id),
goals: g,
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
} as ProjectWithGoals;
});
}
function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
issueId: row.issueId ?? null,
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
scopeId: row.scopeId ?? null,
serviceName: row.serviceName,
status: row.status as WorkspaceRuntimeService["status"],
lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"],
reuseKey: row.reuseKey ?? null,
command: row.command ?? null,
cwd: row.cwd ?? null,
port: row.port ?? null,
url: row.url ?? null,
provider: row.provider as WorkspaceRuntimeService["provider"],
providerRef: row.providerRef ?? null,
ownerAgentId: row.ownerAgentId ?? null,
startedByRunId: row.startedByRunId ?? null,
lastUsedAt: row.lastUsedAt,
startedAt: row.startedAt,
stoppedAt: row.stoppedAt ?? null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toWorkspace(
row: ProjectWorkspaceRow,
runtimeServices: WorkspaceRuntimeService[] = [],
): ProjectWorkspace {
return {
id: row.id,
companyId: row.companyId,
@@ -89,15 +130,20 @@ function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
repoRef: row.repoRef ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
isPrimary: row.isPrimary,
runtimeServices,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null {
function pickPrimaryWorkspace(
rows: ProjectWorkspaceRow[],
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
): ProjectWorkspace | null {
if (rows.length === 0) return null;
const explicitPrimary = rows.find((row) => row.isPrimary);
return toWorkspace(explicitPrimary ?? rows[0]);
const primary = explicitPrimary ?? rows[0];
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
}
/** Batch-load workspace refs for a set of projects. */
@@ -110,6 +156,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
db,
rows[0]!.companyId,
workspaceRows.map((workspace) => workspace.id),
);
const sharedRuntimeServicesByWorkspaceId = new Map(
Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [
workspaceId,
services.map(toRuntimeService),
]),
);
const map = new Map<string, ProjectWorkspaceRow[]>();
for (const row of workspaceRows) {
@@ -123,11 +180,16 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
return rows.map((row) => {
const projectWorkspaceRows = map.get(row.id) ?? [];
const workspaces = projectWorkspaceRows.map(toWorkspace);
const workspaces = projectWorkspaceRows.map((workspace) =>
toWorkspace(
workspace,
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
),
);
return {
...row,
workspaces,
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows),
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
};
});
}
@@ -402,7 +464,18 @@ export function projectService(db: Db) {
.from(projectWorkspaces)
.where(eq(projectWorkspaces.projectId, projectId))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
return rows.map(toWorkspace);
if (rows.length === 0) return [];
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
db,
rows[0]!.companyId,
rows.map((workspace) => workspace.id),
);
return rows.map((row) =>
toWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
),
);
},
createWorkspace: async (

File diff suppressed because it is too large Load Diff

202
skills/pr-report/SKILL.md Normal file
View File

@@ -0,0 +1,202 @@
---
name: pr-report
description: >
Review a pull request or contribution deeply, explain it tutorial-style for a
maintainer, and produce a polished report artifact such as HTML or Markdown.
Use when asked to analyze a PR, explain a contributor's design decisions,
compare it with similar systems, or prepare a merge recommendation.
---
# PR Report Skill
Produce a maintainer-grade review of a PR, branch, or large contribution.
Default posture:
- understand the change before judging it
- explain the system as built, not just the diff
- separate architectural problems from product-scope objections
- make a concrete recommendation, not a vague impression
## When to Use
Use this skill when the user asks for things like:
- "review this PR deeply"
- "explain this contribution to me"
- "make me a report or webpage for this PR"
- "compare this design to similar systems"
- "should I merge this?"
## Outputs
Common outputs:
- standalone HTML report in `tmp/reports/...`
- Markdown report in `report/` or another requested folder
- short maintainer summary in chat
If the user asks for a webpage, build a polished standalone HTML artifact with
clear sections and readable visual hierarchy.
Resources bundled with this skill:
- `references/style-guide.md` for visual direction and report presentation rules
- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter
## Workflow
### 1. Acquire and frame the target
Work from local code when possible, not just the GitHub PR page.
Gather:
- target branch or worktree
- diff size and changed subsystems
- relevant repo docs, specs, and invariants
- contributor intent if it is documented in PR text or design docs
Start by answering: what is this change *trying* to become?
### 2. Build a mental model of the system
Do not stop at file-by-file notes. Reconstruct the design:
- what new runtime or contract exists
- which layers changed: db, shared types, server, UI, CLI, docs
- lifecycle: install, startup, execution, UI, failure, disablement
- trust boundary: what code runs where, under what authority
For large contributions, include a tutorial-style section that teaches the
system from first principles.
### 3. Review like a maintainer
Findings come first. Order by severity.
Prioritize:
- behavioral regressions
- trust or security gaps
- misleading abstractions
- lifecycle and operational risks
- coupling that will be hard to unwind
- missing tests or unverifiable claims
Always cite concrete file references when possible.
### 4. Distinguish the objection type
Be explicit about whether a concern is:
- product direction
- architecture
- implementation quality
- rollout strategy
- documentation honesty
Do not hide an architectural objection inside a scope objection.
### 5. Compare to external precedents when needed
If the contribution introduces a framework or platform concept, compare it to
similar open-source systems.
When comparing:
- prefer official docs or source
- focus on extension boundaries, context passing, trust model, and UI ownership
- extract lessons, not just similarities
Good comparison questions:
- Who owns lifecycle?
- Who owns UI composition?
- Is context explicit or ambient?
- Are plugins trusted code or sandboxed code?
- Are extension points named and typed?
### 6. Make the recommendation actionable
Do not stop at "merge" or "do not merge."
Choose one:
- merge as-is
- merge after specific redesign
- salvage specific pieces
- keep as design research
If rejecting or narrowing, say what should be kept.
Useful recommendation buckets:
- keep the protocol/type model
- redesign the UI boundary
- narrow the initial surface area
- defer third-party execution
- ship a host-owned extension-point model first
### 7. Build the artifact
Suggested report structure:
1. Executive summary
2. What the PR actually adds
3. Tutorial: how the system works
4. Strengths
5. Main findings
6. Comparisons
7. Recommendation
For HTML reports:
- use intentional typography and color
- make navigation easy for long reports
- favor strong section headings and small reference labels
- avoid generic dashboard styling
Before building from scratch, read `references/style-guide.md`.
If a fast polished starter is helpful, begin from `assets/html-report-starter.html`
and replace the placeholder content with the actual report.
### 8. Verify before handoff
Check:
- artifact path exists
- findings still match the actual code
- any requested forbidden strings are absent from generated output
- if tests were not run, say so explicitly
## Review Heuristics
### Plugin and platform work
Watch closely for:
- docs claiming sandboxing while runtime executes trusted host processes
- module-global state used to smuggle React context
- hidden dependence on render order
- plugins reaching into host internals instead of using explicit APIs
- "capabilities" that are really policy labels on top of fully trusted code
### Good signs
- typed contracts shared across layers
- explicit extension points
- host-owned lifecycle
- honest trust model
- narrow first rollout with room to grow
## Final Response
In chat, summarize:
- where the report is
- your overall call
- the top one or two reasons
- whether verification or tests were skipped
Keep the chat summary shorter than the report itself.

View File

@@ -0,0 +1,426 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR Report Starter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #f4efe5;
--paper: rgba(255, 251, 244, 0.88);
--paper-strong: #fffaf1;
--ink: #1f1b17;
--muted: #6a6257;
--line: rgba(31, 27, 23, 0.12);
--accent: #9c4729;
--accent-soft: rgba(156, 71, 41, 0.1);
--good: #2f6a42;
--warn: #946200;
--bad: #8c2f25;
--shadow: 0 22px 60px rgba(52, 37, 19, 0.1);
--radius: 20px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
background:
radial-gradient(circle at top left, rgba(156, 71, 41, 0.12), transparent 34rem),
radial-gradient(circle at top right, rgba(47, 106, 66, 0.08), transparent 28rem),
linear-gradient(180deg, #efe6d6 0%, var(--bg) 48%, #ece5d8 100%);
}
.shell {
width: min(1360px, calc(100vw - 32px));
margin: 24px auto;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
}
.panel {
background: var(--paper);
backdrop-filter: blur(12px);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.nav {
position: sticky;
top: 20px;
align-self: start;
padding: 22px;
}
.eyebrow {
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.nav h1,
.hero h1,
h2,
h3 {
font-family: "Newsreader", serif;
line-height: 0.96;
margin: 0;
}
.nav h1 {
font-size: 2rem;
margin-top: 10px;
}
.nav p {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.5;
}
.nav ul {
list-style: none;
padding: 0;
margin: 18px 0 0;
display: grid;
gap: 10px;
}
.nav a {
display: block;
color: var(--ink);
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.35);
}
.nav a:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.75);
}
.meta-block {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
main {
display: grid;
gap: 24px;
}
section {
padding: 26px 28px 28px;
}
.hero {
padding: 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -3rem -6rem auto;
width: 18rem;
height: 18rem;
border-radius: 50%;
background: radial-gradient(circle, rgba(156, 71, 41, 0.14), transparent 68%);
pointer-events: none;
}
.hero h1 {
font-size: clamp(2.6rem, 5vw, 4.6rem);
max-width: 12ch;
margin-top: 12px;
}
.lede {
margin-top: 16px;
max-width: 70ch;
font-size: 1.05rem;
line-height: 1.65;
color: #2b2723;
}
.hero-grid,
.card-grid,
.two-col {
display: grid;
gap: 14px;
}
.hero-grid {
margin-top: 24px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric,
.card,
.finding {
padding: 18px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--line);
border-radius: 18px;
}
.metric .label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric .value {
margin-top: 8px;
font-size: 1.45rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
margin-bottom: 16px;
}
h3 {
font-size: 1.3rem;
margin-bottom: 10px;
}
p {
margin: 0 0 14px;
line-height: 1.65;
}
ul,
ol {
margin: 0;
padding-left: 20px;
line-height: 1.65;
}
li + li {
margin-top: 8px;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.68);
}
.badge.good {
color: var(--good);
}
.badge.warn {
color: var(--warn);
}
.badge.bad {
color: var(--bad);
}
.quote {
margin-top: 18px;
padding: 18px;
border-left: 4px solid var(--accent);
border-radius: 14px;
background: var(--accent-soft);
}
.severity {
display: inline-flex;
margin-bottom: 12px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.severity.high {
background: rgba(140, 47, 37, 0.12);
color: var(--bad);
}
.severity.medium {
background: rgba(148, 98, 0, 0.12);
color: var(--warn);
}
.severity.low {
background: rgba(47, 106, 66, 0.12);
color: var(--good);
}
.ref {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.nav {
position: static;
}
.hero-grid,
.card-grid,
.two-col {
grid-template-columns: 1fr;
}
.hero h1 {
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel nav">
<div class="eyebrow">Maintainer Report</div>
<h1>Report Title</h1>
<p>Replace this with a concise description of what the report covers.</p>
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#tutorial">Tutorial</a></li>
<li><a href="#findings">Findings</a></li>
<li><a href="#recommendation">Recommendation</a></li>
</ul>
<div class="meta-block">
Replace with project metadata, review date, or scope notes.
</div>
</aside>
<main>
<section class="panel hero" id="summary">
<div class="eyebrow">Executive Summary</div>
<h1>Use the hero for the clearest one-line judgment.</h1>
<p class="lede">
Replace this with the short explanation of what the contribution does, why it matters,
and what the core maintainer question is.
</p>
<div class="badge-row">
<span class="badge good">Strength</span>
<span class="badge warn">Tradeoff</span>
<span class="badge bad">Risk</span>
</div>
<div class="hero-grid">
<div class="metric">
<div class="label">Overall Call</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Main Concern</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Best Part</div>
<div class="value">Placeholder</div>
</div>
<div class="metric">
<div class="label">Weakest Part</div>
<div class="value">Placeholder</div>
</div>
</div>
<div class="quote">
Use this block for the thesis, a sharp takeaway, or a key cited point.
</div>
</section>
<section class="panel" id="tutorial">
<h2>Tutorial Section</h2>
<div class="two-col">
<div class="card">
<h3>Concept Card</h3>
<p>Use cards for mental models, subsystems, or comparison slices.</p>
<div class="ref">path/to/file.ts:10</div>
</div>
<div class="card">
<h3>Second Card</h3>
<p>Keep cards fairly dense. This template is about style, not fixed structure.</p>
<div class="ref">path/to/file.ts:20</div>
</div>
</div>
</section>
<section class="panel" id="findings">
<h2>Findings</h2>
<article class="finding">
<div class="severity high">High</div>
<h3>Finding Title</h3>
<p>Use findings for the sharpest judgment calls and risks.</p>
<div class="ref">path/to/file.ts:30</div>
</article>
</section>
<section class="panel" id="recommendation">
<h2>Recommendation</h2>
<div class="card-grid">
<div class="card">
<h3>Path Forward</h3>
<p>Use this area for merge guidance, salvage plan, or rollout advice.</p>
</div>
<div class="card">
<h3>What To Keep</h3>
<p>Call out the parts worth preserving even if the whole proposal should not land.</p>
</div>
</div>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,149 @@
# PR Report Style Guide
Use this guide when the user wants a report artifact, especially a webpage.
## Goal
Make the report feel like an editorial review, not an internal admin dashboard.
The page should make a long technical argument easy to scan without looking
generic or overdesigned.
## Visual Direction
Preferred tone:
- editorial
- warm
- serious
- high-contrast
- handcrafted, not corporate SaaS
Avoid:
- default app-shell layouts
- purple gradients on white
- generic card dashboards
- cramped pages with weak hierarchy
- novelty fonts that hurt readability
## Typography
Recommended pattern:
- one expressive serif or display face for major headings
- one sturdy sans-serif for body copy and UI labels
Good combinations:
- Newsreader + IBM Plex Sans
- Source Serif 4 + Instrument Sans
- Fraunces + Public Sans
- Libre Baskerville + Work Sans
Rules:
- headings should feel deliberate and large
- body copy should stay comfortable for long reading
- reference labels and badges should use smaller dense sans text
## Layout
Recommended structure:
- a sticky side or top navigation for long reports
- one strong hero summary at the top
- panel or paper-like sections for each major topic
- multi-column card grids for comparisons and strengths
- single-column body text for findings and recommendations
Use generous spacing. Long-form technical reports need breathing room.
## Color
Prefer muted paper-like backgrounds with one warm accent and one cool counterweight.
Suggested token categories:
- `--bg`
- `--paper`
- `--ink`
- `--muted`
- `--line`
- `--accent`
- `--good`
- `--warn`
- `--bad`
The accent should highlight navigation, badges, and important labels. Do not
let accent colors dominate body text.
## Useful UI Elements
Include small reusable styles for:
- summary metrics
- badges
- quotes or callouts
- finding cards
- severity labels
- reference labels
- comparison cards
- responsive two-column sections
## Motion
Keep motion restrained.
Good:
- soft fade/slide-in on first load
- hover response on nav items or cards
Bad:
- constant animation
- floating blobs
- decorative motion with no reading benefit
## Content Presentation
Even when the user wants design polish, clarity stays primary.
Good structure for long reports:
1. executive summary
2. what changed
3. tutorial explanation
4. strengths
5. findings
6. comparisons
7. recommendation
The exact headings can change. The important thing is to separate explanation
from judgment.
## References
Reference labels should be visually quiet but easy to spot.
Good pattern:
- small muted text
- monospace or compact sans
- keep them close to the paragraph they support
## Starter Usage
If you need a fast polished base, start from:
- `assets/html-report-starter.html`
Customize:
- fonts
- color tokens
- hero copy
- section ordering
- card density
Do not preserve the placeholder sections if they do not fit the actual report.

Some files were not shown because too many files have changed in this diff Show More