Compare commits
No commits in common. "PAP-878-create-a-mine-tab-in-inbox" and "pr/pap-817-cli-api-connection-errors" have entirely different histories.
PAP-878-cr
...
pr/pap-817
180 changed files with 1817 additions and 19130 deletions
40
.github/workflows/pr.yml
vendored
40
.github/workflows/pr.yml
vendored
|
|
@ -40,46 +40,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
||||||
- name: Validate Dockerfile deps stage
|
|
||||||
run: |
|
|
||||||
missing=0
|
|
||||||
|
|
||||||
# Extract only the deps stage from the Dockerfile
|
|
||||||
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
|
|
||||||
|
|
||||||
if [ -z "$deps_stage" ]; then
|
|
||||||
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
|
|
||||||
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
|
|
||||||
|
|
||||||
if [ -z "$search_roots" ]; then
|
|
||||||
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check all workspace package.json files are copied in the deps stage
|
|
||||||
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
|
|
||||||
dir="$(dirname "$pkg")"
|
|
||||||
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
|
|
||||||
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
|
|
||||||
missing=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Check patches directory is copied if it exists
|
|
||||||
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
|
|
||||||
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
|
|
||||||
missing=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$missing" -eq 1 ]; then
|
|
||||||
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Validate dependency resolution when manifests change
|
- name: Validate dependency resolution when manifests change
|
||||||
run: |
|
run: |
|
||||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Nexus Rebase Runbook
|
|
||||||
|
|
||||||
Step-by-step workflow for rebasing Nexus fork commits onto new upstream Paperclip releases.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `git rerere` enabled: `git config rerere.enabled true`
|
|
||||||
- `git range-diff` available (git 2.19+, confirmed 2.39.5 on this machine)
|
|
||||||
- Upstream remote configured: `git remote add upstream https://github.com/paperclipai/paperclip.git` (if not already)
|
|
||||||
|
|
||||||
## Pre-Rebase Checklist
|
|
||||||
|
|
||||||
1. Ensure working tree is clean: `git status`
|
|
||||||
2. Fetch upstream: `git fetch upstream`
|
|
||||||
3. Record current tip: `git log --oneline -1` (save this SHA as OLD_TIP)
|
|
||||||
4. Verify all tests pass before rebase: `pnpm test:run`
|
|
||||||
|
|
||||||
## Rebase Procedure
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Fetch latest upstream
|
|
||||||
git fetch upstream
|
|
||||||
|
|
||||||
# 2. Rebase nexus commits onto upstream/master
|
|
||||||
git rebase upstream/master
|
|
||||||
|
|
||||||
# 3. If conflicts arise:
|
|
||||||
# - git rerere will auto-apply previously recorded resolutions
|
|
||||||
# - For new conflicts: resolve manually, then `git add` + `git rebase --continue`
|
|
||||||
# - rerere automatically records new resolutions for future use
|
|
||||||
|
|
||||||
# 4. Verify rebase integrity with range-diff
|
|
||||||
# ORIG_HEAD is the pre-rebase tip (set automatically by git)
|
|
||||||
git range-diff upstream/master ORIG_HEAD HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
## Post-Rebase Verification
|
|
||||||
|
|
||||||
1. **range-diff check:** `git range-diff upstream/master ORIG_HEAD HEAD`
|
|
||||||
- Every nexus commit should show as "equivalent" (minor offset changes only)
|
|
||||||
- Flag any commit showing significant diff changes for manual review
|
|
||||||
2. **Test suite:** `pnpm test:run` — all tests must pass
|
|
||||||
3. **Type check:** `pnpm typecheck` (if available) or `pnpm -r run typecheck`
|
|
||||||
4. **Branding spot check:** `pnpm vitest run --project packages/branding`
|
|
||||||
|
|
||||||
## Handling Common Scenarios
|
|
||||||
|
|
||||||
### Upstream changed a file we also changed (DISPLAY zone)
|
|
||||||
- Most common: string changes in UI components
|
|
||||||
- rerere should handle if previously resolved
|
|
||||||
- If new: resolve keeping Nexus display string, `git add`, continue
|
|
||||||
|
|
||||||
### Upstream added new constants to packages/shared/src/constants.ts
|
|
||||||
- Our changes are in `packages/branding/` (separate file) — no conflict expected
|
|
||||||
- If AGENT_ROLE_LABELS format changes upstream, update the DISPLAY zone mapping
|
|
||||||
|
|
||||||
### Upstream restructured a file entirely
|
|
||||||
- range-diff will show the affected nexus commit as "changed"
|
|
||||||
- Manually verify the nexus change still applies correctly
|
|
||||||
- Update zone taxonomy if file paths changed
|
|
||||||
|
|
||||||
## rerere Cache Notes
|
|
||||||
|
|
||||||
- Cache lives in `.git/rr-cache/` (not tracked by git)
|
|
||||||
- Cache is machine-local — lost on re-clone
|
|
||||||
- After a fresh clone, first rebase may require manual resolution
|
|
||||||
- Subsequent rebases at the same conflict points will auto-resolve
|
|
||||||
|
|
||||||
## Hook Re-installation
|
|
||||||
|
|
||||||
After a fresh clone, the commit-msg hook must be reinstalled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From repo root:
|
|
||||||
cp scripts/nexus-commit-msg-hook.sh .git/hooks/commit-msg
|
|
||||||
chmod +x .git/hooks/commit-msg
|
|
||||||
```
|
|
||||||
|
|
||||||
Or using the install script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash scripts/install-hooks.sh
|
|
||||||
```
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# Nexus Zone Taxonomy
|
|
||||||
|
|
||||||
Classifies every Paperclip-to-Nexus rename target by zone.
|
|
||||||
Zones determine which occurrences are safe to change and which must stay unchanged for upstream sync.
|
|
||||||
|
|
||||||
**Zones:**
|
|
||||||
- **DISPLAY** — User-facing strings safe to rename (UI text, banners, tooltips, help text, button labels)
|
|
||||||
- **CODE** — TypeScript identifiers, import paths, route segments, env vars — do NOT touch
|
|
||||||
- **STORED** — DB column/table names, stored enum values — do NOT touch
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DISPLAY Zone (safe to change in Phases 2-4)
|
|
||||||
|
|
||||||
| Target | Location | Current Value | Nexus Value | Phase |
|
|
||||||
|--------|----------|---------------|-------------|-------|
|
|
||||||
| Company display string in JSX | ~16 UI files in `ui/src/` | "Company" | "Workspace" | 3 |
|
|
||||||
| Companies plural in JSX | UI files | "Companies" | "Workspaces" | 3 |
|
|
||||||
| CEO display string in JSX | `ui/src/components/agent-config-primitives.tsx`, `AgentProperties.tsx`, etc. | "CEO" | "Project Manager" | 3 |
|
|
||||||
| Board display string in JSX | Various UI files | "Board" | "Owner" | 3 |
|
|
||||||
| Hire button text | UI dialogs | "Hire" | "Add" | 3 |
|
|
||||||
| Fire button text | UI dialogs | "Fire" | "Remove" | 3 |
|
|
||||||
| `AGENT_ROLE_LABELS.ceo` value | `packages/shared/src/constants.ts` | `"CEO"` | `"Project Manager"` | 2 |
|
|
||||||
| PAPERCLIP ASCII banner | `server/src/startup-banner.ts` | "PAPERCLIP" | "NEXUS" | 2 |
|
|
||||||
| PAPERCLIP ASCII banner (CLI) | `cli/src/utils/banner.ts` | "PAPERCLIP" | "NEXUS" | 2 |
|
|
||||||
| App title in browser tab | `ui/index.html` or layout | "Paperclip" | "Nexus" | 3 |
|
|
||||||
| Top-left logo text | UI layout component | "Paperclip" | "Nexus" | 3 |
|
|
||||||
| CLI help text brand name | `cli/src/` command descriptions | "Paperclip" | "Nexus" | 3 |
|
|
||||||
| paperclip.ing URL references | `ui/src/pages/CompanyExport.tsx` | "paperclip.ing" | Nexus URL | 3 |
|
|
||||||
| Favicon and logo assets | `ui/public/` or assets dir | Paperclip branding | Nexus branding | 3 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CODE Zone (do NOT touch — upstream sync priority)
|
|
||||||
|
|
||||||
| Target | Location | Rationale |
|
|
||||||
|--------|----------|-----------|
|
|
||||||
| `companyService`, `companyId`, `selectedCompanyId` | Throughout server/ui/cli | TypeScript identifiers — hundreds of import references |
|
|
||||||
| `companies` table name | `packages/db/src/schema/` | DB table — migration required to rename |
|
|
||||||
| `company_id` FK columns | `packages/db/src/schema/` | DB columns — migration required |
|
|
||||||
| `/api/companies` route segment | `server/src/routes/companies.ts` | API contract — client/server must match |
|
|
||||||
| `COMPANY_STATUSES` / `CompanyStatus` type | `packages/shared/src/constants.ts` | Upstream shared type — plugin API contract |
|
|
||||||
| `@paperclipai/*` package names | All `package.json` files | Import paths throughout monorepo |
|
|
||||||
| `PAPERCLIP_*` env vars | Server/CLI config | Breaks existing deployments |
|
|
||||||
| `board_api_keys` table / `board` actor type | DB schema, auth code | Auth token format, DB schema |
|
|
||||||
| `pcp_board_*` token prefixes | Auth code | Would invalidate issued tokens |
|
|
||||||
| `.paperclip.yaml` export format | Import/export code | Upstream compatibility |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## STORED Zone (do NOT touch — DB integrity)
|
|
||||||
|
|
||||||
| Target | Location | Stored Where | Rationale |
|
|
||||||
|--------|----------|-------------|-----------|
|
|
||||||
| `"ceo"` in `AGENT_ROLES` | `packages/shared/src/constants.ts` | `agent_role` DB column | Existing rows contain this value |
|
|
||||||
| `"hire_agent"` approval type | `packages/shared/src/constants.ts` APPROVAL_TYPES | `approval_type` DB column | Existing approvals reference this |
|
|
||||||
| `"approve_ceo_strategy"` | `packages/shared/src/constants.ts` APPROVAL_TYPES | `approval_type` DB column | Existing approvals reference this |
|
|
||||||
| `"bootstrap_ceo"` invite type | `packages/shared/src/constants.ts` | `invite_type` DB column | Existing invites reference this |
|
|
||||||
| `company_id` FK values | All FK columns | PostgreSQL foreign keys | Data integrity constraint |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zone Summary
|
|
||||||
|
|
||||||
| Zone | Count | Rule |
|
|
||||||
|------|-------|------|
|
|
||||||
| DISPLAY | ~40 surface points | Safe to rename in Phases 2-4 |
|
|
||||||
| CODE | Many hundreds | Never rename — upstream sync priority |
|
|
||||||
| STORED | ~8 enum/column values | Never rename — DB integrity |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision Rule
|
|
||||||
|
|
||||||
When the same term appears in multiple zones (e.g., "ceo" is both STORED as `AGENT_ROLES[0]` and DISPLAY as `AGENT_ROLE_LABELS.ceo` value), classify each occurrence independently. The key stays, only the display value changes.
|
|
||||||
|
|
||||||
**Example:** `AGENT_ROLES` contains `"ceo"` (STORED — do not touch). `AGENT_ROLE_LABELS.ceo` has value `"CEO"` (DISPLAY — safe to change to `"Project Manager"`). Both live in the same file (`packages/shared/src/constants.ts`), but the treatment differs per occurrence.
|
|
||||||
|
|
@ -26,9 +26,6 @@ Before making changes, read in this order:
|
||||||
- `ui/`: React + Vite board UI
|
- `ui/`: React + Vite board UI
|
||||||
- `packages/db/`: Drizzle schema, migrations, DB clients
|
- `packages/db/`: Drizzle schema, migrations, DB clients
|
||||||
- `packages/shared/`: shared types, constants, validators, API path constants
|
- `packages/shared/`: shared types, constants, validators, API path constants
|
||||||
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
|
|
||||||
- `packages/adapter-utils/`: shared adapter utilities
|
|
||||||
- `packages/plugins/`: plugin system packages
|
|
||||||
- `doc/`: operational and product docs
|
- `doc/`: operational and product docs
|
||||||
|
|
||||||
## 4. Dev Setup (Auto DB)
|
## 4. Dev Setup (Auto DB)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-
|
||||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||||
COPY patches/ patches/
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -234,27 +234,16 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
|
- ⚪ Get OpenClaw onboarding easier
|
||||||
- ✅ Get OpenClaw / claw-style agent employees
|
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
||||||
- ✅ companies.sh - import and export entire organizations
|
- ⚪ ClipMart - buy and sell entire agent companies
|
||||||
- ✅ Easy AGENTS.md configurations
|
- ⚪ Easy agent configurations / easier to understand
|
||||||
- ✅ Skills Manager
|
- ⚪ Better support for harness engineering
|
||||||
- ✅ Scheduled Routines
|
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||||
- ✅ Better Budgeting
|
- ⚪ Better docs
|
||||||
- ⚪ Artifacts & Deployments
|
|
||||||
- ⚪ CEO Chat
|
|
||||||
- ⚪ MAXIMIZER MODE
|
|
||||||
- ⚪ Multiple Human Users
|
|
||||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
|
||||||
- ⚪ Cloud deployments
|
|
||||||
- ⚪ Desktop App
|
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## Community & Plugins
|
|
||||||
|
|
||||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/branding": "workspace:*",
|
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
"@paperclipai/server": "workspace:*",
|
"@paperclipai/server": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,33 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
import { createStoredZipArchive } from "./helpers/zip.js";
|
import { createStoredZipArchive } from "./helpers/zip.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;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
type ServerProcess = ReturnType<typeof spawn>;
|
type ServerProcess = ReturnType<typeof spawn>;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
|
|
@ -35,13 +53,30 @@ async function getAvailablePort(): Promise<number> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
async function startTempDatabase() {
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db");
|
||||||
console.warn(
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
);
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
|
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
|
||||||
|
|
@ -230,23 +265,26 @@ async function waitForServer(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
describe("paperclipai company import/export e2e", () => {
|
||||||
let tempRoot = "";
|
let tempRoot = "";
|
||||||
let configPath = "";
|
let configPath = "";
|
||||||
let exportDir = "";
|
let exportDir = "";
|
||||||
let apiBase = "";
|
let apiBase = "";
|
||||||
let serverProcess: ServerProcess | null = null;
|
let serverProcess: ServerProcess | null = null;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let dbDataDir = "";
|
||||||
|
let dbInstance: EmbeddedPostgresInstance | null = null;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
||||||
configPath = path.join(tempRoot, "config", "config.json");
|
configPath = path.join(tempRoot, "config", "config.json");
|
||||||
exportDir = path.join(tempRoot, "exported-company");
|
exportDir = path.join(tempRoot, "exported-company");
|
||||||
|
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
|
const db = await startTempDatabase();
|
||||||
|
dbDataDir = db.dataDir;
|
||||||
|
dbInstance = db.instance;
|
||||||
|
|
||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
|
writeTestConfig(configPath, tempRoot, port, db.connectionString);
|
||||||
apiBase = `http://127.0.0.1:${port}`;
|
apiBase = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
|
|
@ -256,7 +294,7 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||||
["paperclipai", "run", "--config", configPath],
|
["paperclipai", "run", "--config", configPath],
|
||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: createServerEnv(configPath, port, tempDb.connectionString),
|
env: createServerEnv(configPath, port, db.connectionString),
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -273,7 +311,10 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await stopServerProcess(serverProcess);
|
await stopServerProcess(serverProcess);
|
||||||
await tempDb?.cleanup();
|
await dbInstance?.stop();
|
||||||
|
if (dbDataDir) {
|
||||||
|
rmSync(dbDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
if (tempRoot) {
|
if (tempRoot) {
|
||||||
rmSync(tempRoot, { recursive: true, force: true });
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ describe("renderCompanyImportPreview", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(rendered).toContain("Include");
|
expect(rendered).toContain("Include");
|
||||||
expect(rendered).toContain("workspace, projects, tasks, agents, skills"); // [nexus] updated from "company" to "workspace"
|
expect(rendered).toContain("company, projects, tasks, agents, skills");
|
||||||
expect(rendered).toContain("7 agents total");
|
expect(rendered).toContain("7 agents total");
|
||||||
expect(rendered).toContain("1 project total");
|
expect(rendered).toContain("1 project total");
|
||||||
expect(rendered).toContain("1 task total");
|
expect(rendered).toContain("1 task total");
|
||||||
|
|
@ -319,7 +319,7 @@ describe("renderCompanyImportResult", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(rendered).toContain("Workspace"); // [nexus] updated from "Company" to "Workspace"
|
expect(rendered).toContain("Company");
|
||||||
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
|
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
|
||||||
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
|
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
|
||||||
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
|
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestSupport,
|
|
||||||
} from "@paperclipai/db";
|
|
||||||
|
|
@ -72,7 +72,7 @@ describe("PaperclipApiClient", () => {
|
||||||
causeMessage: "fetch failed",
|
causeMessage: "fetch failed",
|
||||||
} satisfies Partial<ApiConnectionError>);
|
} satisfies Partial<ApiConnectionError>);
|
||||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||||
/Could not reach the Nexus API\./, // [nexus] updated from "Paperclip API" to "Nexus API"
|
/Could not reach the Paperclip API\./,
|
||||||
);
|
);
|
||||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||||
/curl http:\/\/localhost:3100\/api\/health/,
|
/curl http:\/\/localhost:3100\/api\/health/,
|
||||||
|
|
|
||||||
|
|
@ -344,87 +344,6 @@ describe("worktree helpers", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
|
||||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
|
||||||
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(siblingInstanceRoot, "config.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
...buildSourceConfig(),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54330,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(siblingInstanceRoot, "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file",
|
|
||||||
logDir: path.join(siblingInstanceRoot, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "authenticated",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3101,
|
|
||||||
allowedHostnames: ["localhost"],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.join(siblingInstanceRoot, "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: "local_encrypted",
|
|
||||||
strictMode: false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(repoRoot);
|
|
||||||
await worktreeInitCommand({
|
|
||||||
seed: false,
|
|
||||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
|
||||||
home: homeDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
|
||||||
expect(config.server.port).toBe(3102);
|
|
||||||
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
|
||||||
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
|
||||||
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
|
||||||
} finally {
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
const repoRoot = path.join(tempRoot, "repo");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
import { buildCliCommandLabel } from "./command-label.js";
|
import { buildCliCommandLabel } from "./command-label.js";
|
||||||
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
||||||
|
|
||||||
|
|
@ -216,7 +215,7 @@ export async function loginBoardCli(params: {
|
||||||
|
|
||||||
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
||||||
if (params.print !== false) {
|
if (params.print !== false) {
|
||||||
console.error(pc.bold(`${VOCAB.board} authentication required`)); // [nexus]
|
console.error(pc.bold("Board authentication required"));
|
||||||
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
|
@ -206,7 +205,7 @@ function buildConnectionErrorMessage(input: {
|
||||||
}): string {
|
}): string {
|
||||||
const healthUrl = buildHealthCheckUrl(input.url);
|
const healthUrl = buildHealthCheckUrl(input.url);
|
||||||
const lines = [
|
const lines = [
|
||||||
`Could not reach the ${VOCAB.appName} API.`, // [nexus]
|
"Could not reach the Paperclip API.",
|
||||||
"",
|
"",
|
||||||
`Request: ${input.method} ${input.url}`,
|
`Request: ${input.method} ${input.url}`,
|
||||||
];
|
];
|
||||||
|
|
@ -215,12 +214,12 @@ function buildConnectionErrorMessage(input: {
|
||||||
}
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
"",
|
"",
|
||||||
`This usually means the ${VOCAB.appName} server is not running, the configured URL is wrong, or the request is being blocked before it reaches ${VOCAB.appName}.`, // [nexus]
|
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||||
"",
|
"",
|
||||||
"Try:",
|
"Try:",
|
||||||
`- Start ${VOCAB.appName} with \`pnpm dev\` or \`pnpm paperclipai run\`.`, // [nexus]
|
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||||
`- If ${VOCAB.appName} is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`, // [nexus]
|
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||||
);
|
);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
|
|
@ -58,12 +57,12 @@ export async function bootstrapCeoInvite(opts: {
|
||||||
loadPaperclipEnvFile(configPath);
|
loadPaperclipEnvFile(configPath);
|
||||||
const config = readConfig(configPath);
|
const config = readConfig(configPath);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("nexus onboard")} first.`); // [nexus]
|
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.server.deploymentMode !== "authenticated") {
|
if (config.server.deploymentMode !== "authenticated") {
|
||||||
p.log.info(`Deployment mode is local_trusted. Bootstrap ${VOCAB.ceo} invite is only required for authenticated mode.`); // [nexus]
|
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,12 +121,12 @@ export async function bootstrapCeoInvite(opts: {
|
||||||
|
|
||||||
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
|
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
|
||||||
const inviteUrl = `${baseUrl}/invite/${token}`;
|
const inviteUrl = `${baseUrl}/invite/${token}`;
|
||||||
p.log.success(`Created bootstrap ${VOCAB.ceo} invite.`); // [nexus]
|
p.log.success("Created bootstrap CEO invite.");
|
||||||
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
|
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
|
||||||
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
|
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(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 ${VOCAB.appName} server and run this command again.`); // [nexus]
|
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
||||||
} finally {
|
} finally {
|
||||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import type {
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
import { ApiRequestError } from "../../client/http.js";
|
import { ApiRequestError } from "../../client/http.js";
|
||||||
import { openUrl } from "../../client/board-auth.js";
|
import { openUrl } from "../../client/board-auth.js";
|
||||||
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
||||||
|
|
@ -79,7 +78,7 @@ const IMPORT_INCLUDE_OPTIONS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "company", label: VOCAB.company, hint: "name, branding, and workspace settings" }, // [nexus]
|
{ value: "company", label: "Company", hint: "name, branding, and company settings" },
|
||||||
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
|
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
|
||||||
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
|
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
|
||||||
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
|
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
|
||||||
|
|
@ -390,8 +389,8 @@ async function promptForImportSelection(preview: CompanyPortabilityPreviewResult
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: "company",
|
value: "company",
|
||||||
label: state.company ? `${VOCAB.company}: included` : `${VOCAB.company}: skipped`, // [nexus]
|
label: state.company ? "Company: included" : "Company: skipped",
|
||||||
hint: catalog.company.files.length > 0 ? `toggle ${VOCAB.company.toLowerCase()} metadata` : `no ${VOCAB.company.toLowerCase()} metadata in package`, // [nexus]
|
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "projects",
|
value: "projects",
|
||||||
|
|
@ -663,7 +662,7 @@ export function renderCompanyImportResult(
|
||||||
): string {
|
): string {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`${pc.bold("Target")} ${meta.targetLabel}`,
|
`${pc.bold("Target")} ${meta.targetLabel}`,
|
||||||
`${pc.bold(VOCAB.company)} ${result.company.name} (${actionChip(result.company.action)})`, // [nexus]
|
`${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`,
|
||||||
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
|
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
|
||||||
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
|
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
|
||||||
];
|
];
|
||||||
|
|
@ -1041,7 +1040,7 @@ function assertDeleteFlags(opts: CompanyDeleteOptions): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerCompanyCommands(program: Command): void {
|
export function registerCompanyCommands(program: Command): void {
|
||||||
const company = program.command("company").description(`${VOCAB.company} operations`) // [nexus];
|
const company = program.command("company").description("Company operations");
|
||||||
|
|
||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
company
|
company
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
resolveDefaultLogsDir,
|
resolveDefaultLogsDir,
|
||||||
resolvePaperclipInstanceId,
|
resolvePaperclipInstanceId,
|
||||||
} from "../config/home.js";
|
} from "../config/home.js";
|
||||||
import { printNexusCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
|
||||||
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
|
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ export async function configure(opts: {
|
||||||
config?: string;
|
config?: string;
|
||||||
section?: string;
|
section?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
resolvePaperclipInstanceId,
|
resolvePaperclipInstanceId,
|
||||||
} from "../config/home.js";
|
} from "../config/home.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
import { printNexusCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
|
||||||
type DbBackupOptions = {
|
type DbBackupOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
|
|
@ -47,7 +47,7 @@ function resolveBackupDir(raw: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
|
||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
type CheckResult,
|
type CheckResult,
|
||||||
} from "../checks/index.js";
|
} from "../checks/index.js";
|
||||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { printNexusCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
|
||||||
const STATUS_ICON = {
|
const STATUS_ICON = {
|
||||||
pass: pc.green("✓"),
|
pass: pc.green("✓"),
|
||||||
|
|
@ -28,7 +28,7 @@ export async function doctor(opts: {
|
||||||
repair?: boolean;
|
repair?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
}): Promise<{ passed: number; warned: number; failed: number }> {
|
}): Promise<{ passed: number; warned: number; failed: number }> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
|
|
||||||
|
|
@ -32,91 +32,7 @@ import {
|
||||||
resolvePaperclipInstanceId,
|
resolvePaperclipInstanceId,
|
||||||
} from "../config/home.js";
|
} from "../config/home.js";
|
||||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||||
import { printNexusCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
|
|
||||||
// [nexus] Auto-create PM and Engineer agents on first run
|
|
||||||
async function bootstrapNexusAgents(serverUrl: string, rootDir: string): Promise<void> {
|
|
||||||
// [nexus] Health-check poll — wait for server to be ready (max 30 seconds)
|
|
||||||
const maxRetries = 30;
|
|
||||||
let serverReady = false;
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${serverUrl}/api/health`);
|
|
||||||
if (res.ok) {
|
|
||||||
serverReady = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// [nexus] Server not ready yet
|
|
||||||
}
|
|
||||||
if (i < maxRetries - 1) {
|
|
||||||
await new Promise<void>((r) => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!serverReady) {
|
|
||||||
console.warn("[nexus] Server did not become ready in 30s, skipping agent bootstrap");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// [nexus] Check if workspace already exists (idempotent — skip if already bootstrapped)
|
|
||||||
const companiesRes = await fetch(`${serverUrl}/api/companies`);
|
|
||||||
if (!companiesRes.ok) {
|
|
||||||
console.warn("[nexus] Could not fetch workspaces, skipping agent bootstrap");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const companies = (await companiesRes.json()) as unknown[];
|
|
||||||
if (companies.length > 0) {
|
|
||||||
return; // [nexus] Already bootstrapped — skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// [nexus] Create workspace
|
|
||||||
p.log.step(`Creating your ${VOCAB.company} workspace...`);
|
|
||||||
const companyRes = await fetch(`${serverUrl}/api/companies`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: VOCAB.appName }),
|
|
||||||
});
|
|
||||||
if (!companyRes.ok) {
|
|
||||||
console.warn("[nexus] Could not create workspace, skipping agent bootstrap");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const company = (await companyRes.json()) as { id: string };
|
|
||||||
|
|
||||||
// [nexus] Create PM agent (role: "ceo" for elevated permissions — displays as Project Manager)
|
|
||||||
p.log.step(`Adding ${VOCAB.ceo} agent...`);
|
|
||||||
await fetch(`${serverUrl}/api/companies/${company.id}/agents`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: "Project Manager",
|
|
||||||
role: "ceo",
|
|
||||||
adapterType: "claude_local",
|
|
||||||
adapterConfig: { cwd: rootDir },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// [nexus] Create Engineer agent
|
|
||||||
p.log.step("Adding Engineer agent...");
|
|
||||||
await fetch(`${serverUrl}/api/companies/${company.id}/agents`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: "Engineer",
|
|
||||||
role: "engineer",
|
|
||||||
adapterType: "claude_local",
|
|
||||||
adapterConfig: { cwd: rootDir },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
p.log.success("Workspace and agents created — you're ready to go!");
|
|
||||||
} catch (err) {
|
|
||||||
// [nexus] Bootstrap failures are warnings, not errors — user can create agents manually
|
|
||||||
console.warn("[nexus] Agent bootstrap failed:", err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetupMode = "quickstart" | "advanced";
|
type SetupMode = "quickstart" | "advanced";
|
||||||
|
|
||||||
|
|
@ -318,8 +234,8 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" nexus onboard "))); // [nexus]
|
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
|
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
|
||||||
p.log.message(
|
p.log.message(
|
||||||
|
|
@ -393,7 +309,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
await db.execute("SELECT 1");
|
await db.execute("SELECT 1");
|
||||||
s.stop("Database connection successful");
|
s.stop("Database connection successful");
|
||||||
} catch {
|
} catch {
|
||||||
s.stop(pc.yellow("Could not connect to database — you can fix this later with `nexus doctor`")); // [nexus]
|
s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclipai doctor`"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,22 +447,22 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
|
|
||||||
p.note(
|
p.note(
|
||||||
[
|
[
|
||||||
`Run: ${pc.cyan("nexus run")}`, // [nexus]
|
`Run: ${pc.cyan("paperclipai run")}`,
|
||||||
`Reconfigure later: ${pc.cyan("nexus configure")}`, // [nexus]
|
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
|
||||||
`Diagnose setup: ${pc.cyan("nexus doctor")}`, // [nexus]
|
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Next commands",
|
"Next commands",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||||
p.log.step(`Generating bootstrap ${VOCAB.ceo} invite`); // [nexus]
|
p.log.step("Generating bootstrap CEO invite");
|
||||||
await bootstrapCeoInvite({ config: configPath });
|
await bootstrapCeoInvite({ config: configPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
let shouldRunNow = opts.run === true || opts.yes === true;
|
let shouldRunNow = opts.run === true || opts.yes === true;
|
||||||
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
|
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
|
||||||
const answer = await p.confirm({
|
const answer = await p.confirm({
|
||||||
message: `Start ${VOCAB.appName} now?`, // [nexus]
|
message: "Start Paperclip now?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (!p.isCancel(answer)) {
|
if (!p.isCancel(answer)) {
|
||||||
|
|
@ -557,24 +473,6 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
if (shouldRunNow && !opts.invokedByRun) {
|
if (shouldRunNow && !opts.invokedByRun) {
|
||||||
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
|
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
|
||||||
const { runCommand } = await import("./run.js");
|
const { runCommand } = await import("./run.js");
|
||||||
// [nexus] Start bootstrap concurrently — health-check poll waits for server readiness
|
|
||||||
const serverUrl = `http://${server.host}:${server.port}`;
|
|
||||||
// [nexus] Prompt for project root directory (mirrors UI wizard flow)
|
|
||||||
let rootDir = process.cwd();
|
|
||||||
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
||||||
const answer = await p.text({
|
|
||||||
message: "Project root directory:",
|
|
||||||
initialValue: process.cwd(),
|
|
||||||
placeholder: process.cwd(),
|
|
||||||
});
|
|
||||||
if (!p.isCancel(answer) && answer) {
|
|
||||||
rootDir = answer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bootstrapNexusAgents(serverUrl, rootDir).catch((err: unknown) => {
|
|
||||||
// [nexus] Bootstrap failures are non-fatal
|
|
||||||
console.warn("[nexus] Agent bootstrap error:", err instanceof Error ? err.message : String(err));
|
|
||||||
});
|
|
||||||
await runCommand({ config: configPath, repair: true, yes: true });
|
await runCommand({ config: configPath, repair: true, yes: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -582,9 +480,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
||||||
p.log.info(
|
p.log.info(
|
||||||
[
|
[
|
||||||
`Bootstrap ${VOCAB.ceo} invite will be created after the server starts.`, // [nexus]
|
"Bootstrap CEO invite will be created after the server starts.",
|
||||||
`Next: ${pc.cyan("nexus run")}`, // [nexus]
|
`Next: ${pc.cyan("paperclipai run")}`,
|
||||||
`Then: ${pc.cyan("nexus auth bootstrap-ceo")}`, // [nexus]
|
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,13 @@ import {
|
||||||
projects,
|
projects,
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
runDatabaseRestore,
|
runDatabaseRestore,
|
||||||
createEmbeddedPostgresLogBuffer,
|
|
||||||
formatEmbeddedPostgresError,
|
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||||
import { expandHomePrefix } from "../config/home.js";
|
import { expandHomePrefix } from "../config/home.js";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||||
import { printNexusCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
||||||
import {
|
import {
|
||||||
buildWorktreeConfig,
|
buildWorktreeConfig,
|
||||||
|
|
@ -467,62 +465,6 @@ async function findAvailablePort(preferredPort: number, reserved = new Set<numbe
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
|
|
||||||
const normalized = path.resolve(cwd);
|
|
||||||
const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`;
|
|
||||||
const index = normalized.indexOf(marker);
|
|
||||||
if (index === -1) return null;
|
|
||||||
const repoRoot = normalized.slice(0, index);
|
|
||||||
return path.resolve(repoRoot, ".paperclip", "worktrees");
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
|
|
||||||
serverPorts: Set<number>;
|
|
||||||
databasePorts: Set<number>;
|
|
||||||
} {
|
|
||||||
const serverPorts = new Set<number>();
|
|
||||||
const databasePorts = new Set<number>();
|
|
||||||
const configPaths = new Set<string>();
|
|
||||||
const instancesDir = path.resolve(homeDir, "instances");
|
|
||||||
if (existsSync(instancesDir)) {
|
|
||||||
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory() || entry.name === currentInstanceId) continue;
|
|
||||||
|
|
||||||
const configPath = path.resolve(instancesDir, entry.name, "config.json");
|
|
||||||
if (existsSync(configPath)) {
|
|
||||||
configPaths.add(configPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd);
|
|
||||||
if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) {
|
|
||||||
for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json");
|
|
||||||
if (existsSync(configPath)) {
|
|
||||||
configPaths.add(configPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const configPath of configPaths) {
|
|
||||||
try {
|
|
||||||
const config = readConfig(configPath);
|
|
||||||
if (config?.server.port) {
|
|
||||||
serverPorts.add(config.server.port);
|
|
||||||
}
|
|
||||||
if (config?.database.mode === "embedded-postgres") {
|
|
||||||
databasePorts.add(config.database.embeddedPostgresPort);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed sibling configs.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { serverPorts, databasePorts };
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectGitBranchName(cwd: string): string | null {
|
function detectGitBranchName(cwd: string): string | null {
|
||||||
try {
|
try {
|
||||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
|
@ -808,39 +750,24 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = await findAvailablePort(preferredPort);
|
const port = await findAvailablePort(preferredPort);
|
||||||
const logBuffer = createEmbeddedPostgresLogBuffer();
|
|
||||||
const instance = new EmbeddedPostgres({
|
const instance = new EmbeddedPostgres({
|
||||||
databaseDir: dataDir,
|
databaseDir: dataDir,
|
||||||
user: "paperclip",
|
user: "paperclip",
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
onLog: logBuffer.append,
|
onLog: () => {},
|
||||||
onError: logBuffer.append,
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||||
try {
|
await instance.initialise();
|
||||||
await instance.initialise();
|
|
||||||
} catch (error) {
|
|
||||||
throw formatEmbeddedPostgresError(error, {
|
|
||||||
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (existsSync(postmasterPidFile)) {
|
if (existsSync(postmasterPidFile)) {
|
||||||
rmSync(postmasterPidFile, { force: true });
|
rmSync(postmasterPidFile, { force: true });
|
||||||
}
|
}
|
||||||
try {
|
await instance.start();
|
||||||
await instance.start();
|
|
||||||
} catch (error) {
|
|
||||||
throw formatEmbeddedPostgresError(error, {
|
|
||||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port,
|
port,
|
||||||
|
|
@ -959,14 +886,10 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
|
|
||||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||||
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
const serverPort = await findAvailablePort(preferredServerPort);
|
||||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||||
const databasePort = await findAvailablePort(
|
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||||
preferredDbPort,
|
|
||||||
new Set([...claimedPorts.databasePorts, serverPort]),
|
|
||||||
);
|
|
||||||
const targetConfig = buildWorktreeConfig({
|
const targetConfig = buildWorktreeConfig({
|
||||||
sourceConfig,
|
sourceConfig,
|
||||||
paths,
|
paths,
|
||||||
|
|
@ -1046,13 +969,13 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||||
await runWorktreeInit(opts);
|
await runWorktreeInit(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||||
|
|
||||||
const name = resolveWorktreeMakeName(nameArg);
|
const name = resolveWorktreeMakeName(nameArg);
|
||||||
|
|
@ -1248,7 +1171,7 @@ function worktreePathHasUncommittedChanges(worktreePath: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
|
export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> {
|
||||||
printNexusCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup ")));
|
||||||
|
|
||||||
const name = resolveWorktreeMakeName(nameArg);
|
const name = resolveWorktreeMakeName(nameArg);
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,10 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const DEFAULT_INSTANCE_ID = "default";
|
const DEFAULT_INSTANCE_ID = "default";
|
||||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
// [nexus] Read ~/.nexus pointer file for custom home directory
|
|
||||||
function resolveNexusPointerFile(): string | null {
|
|
||||||
const pointerPath = path.resolve(os.homedir(), ".nexus");
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(pointerPath, "utf-8").trim();
|
|
||||||
if (raw.length > 0) {
|
|
||||||
// Inline tilde expansion (expandHomePrefix is defined later in this file)
|
|
||||||
const expanded = raw === "~" ? os.homedir()
|
|
||||||
: raw.startsWith("~/") ? path.resolve(os.homedir(), raw.slice(2))
|
|
||||||
: raw;
|
|
||||||
return path.resolve(expanded);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ~/.nexus does not exist or is unreadable — fall through
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvePaperclipHomeDir(): string {
|
export function resolvePaperclipHomeDir(): string {
|
||||||
// [nexus] Pointer-file: ~/.nexus overrides all other home resolution
|
|
||||||
const nexusRoot = resolveNexusPointerFile();
|
|
||||||
if (nexusRoot) return nexusRoot;
|
|
||||||
|
|
||||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||||
return path.resolve(os.homedir(), ".paperclip");
|
return path.resolve(os.homedir(), ".paperclip");
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,14 @@ import { loadPaperclipEnvFile } from "./config/env.js";
|
||||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||||
import { VOCAB } from "@paperclipai/branding"; // [nexus]
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
const DATA_DIR_OPTION_HELP =
|
const DATA_DIR_OPTION_HELP =
|
||||||
`${VOCAB.appName} data directory root (isolates state from ~/.nexus)`; // [nexus]
|
"Paperclip data directory root (isolates state from ~/.paperclip)";
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("paperclipai")
|
.name("paperclipai")
|
||||||
.description(`${VOCAB.appName} CLI — setup, diagnose, and configure your instance`) // [nexus]
|
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||||
.version("0.2.7");
|
.version("0.2.7");
|
||||||
|
|
||||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||||
|
|
@ -47,12 +46,12 @@ program
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
|
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
|
||||||
.option("--run", `Start ${VOCAB.appName} immediately after saving config`, false) // [nexus]
|
.option("--run", "Start Paperclip immediately after saving config", false)
|
||||||
.action(onboard);
|
.action(onboard);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("doctor")
|
.command("doctor")
|
||||||
.description(`Run diagnostic checks on your ${VOCAB.appName} setup`) // [nexus]
|
.description("Run diagnostic checks on your Paperclip setup")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("--repair", "Attempt to repair issues automatically")
|
.option("--repair", "Attempt to repair issues automatically")
|
||||||
|
|
@ -84,7 +83,7 @@ program
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("--dir <path>", "Backup output directory (overrides config)")
|
.option("--dir <path>", "Backup output directory (overrides config)")
|
||||||
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
|
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
|
||||||
.option("--filename-prefix <prefix>", "Backup filename prefix", "nexus") // [nexus]
|
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
|
||||||
.option("--json", "Print backup metadata as JSON")
|
.option("--json", "Print backup metadata as JSON")
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
await dbBackupCommand(opts);
|
await dbBackupCommand(opts);
|
||||||
|
|
@ -100,7 +99,7 @@ program
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("run")
|
.command("run")
|
||||||
.description(`Bootstrap local setup (onboard + doctor) and run ${VOCAB.appName}`) // [nexus]
|
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
||||||
.option("-c, --config <path>", "Path to config file")
|
.option("-c, --config <path>", "Path to config file")
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||||
|
|
@ -118,7 +117,7 @@ heartbeat
|
||||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||||
.option("--context <path>", "Path to CLI context file")
|
.option("--context <path>", "Path to CLI context file")
|
||||||
.option("--profile <name>", "CLI context profile name")
|
.option("--profile <name>", "CLI context profile name")
|
||||||
.option("--api-base <url>", `Base URL for the ${VOCAB.appName} server API`) // [nexus]
|
.option("--api-base <url>", "Base URL for the Paperclip server API")
|
||||||
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
|
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
|
||||||
.option(
|
.option(
|
||||||
"--source <source>",
|
"--source <source>",
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
|
||||||
// [nexus] replaced PAPERCLIP_ART with NEXUS_ART
|
const PAPERCLIP_ART = [
|
||||||
const NEXUS_ART = [
|
"██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ",
|
||||||
"███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗",
|
"██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗",
|
||||||
"████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝",
|
"██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝",
|
||||||
"██╔██╗ ██║█████╗ ╚███╔╝ ██║ ██║███████╗",
|
"██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ",
|
||||||
"██║╚██╗██║██╔══╝ ██╔██╗ ██║ ██║╚════██║",
|
"██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ",
|
||||||
"██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║",
|
"╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ",
|
||||||
"╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// [nexus] updated tagline
|
const TAGLINE = "Open-source orchestration for zero-human companies";
|
||||||
const TAGLINE = "Open-source orchestration for your agents";
|
|
||||||
|
|
||||||
// [nexus] renamed from printPaperclipCliBanner
|
export function printPaperclipCliBanner(): void {
|
||||||
export function printNexusCliBanner(): void {
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"",
|
"",
|
||||||
...NEXUS_ART.map((line) => pc.cyan(line)),
|
...PAPERCLIP_ART.map((line) => pc.cyan(line)),
|
||||||
pc.blue(" ───────────────────────────────────────────────────────"),
|
pc.blue(" ───────────────────────────────────────────────────────"),
|
||||||
pc.bold(pc.white(` ${TAGLINE}`)),
|
pc.bold(pc.white(` ${TAGLINE}`)),
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -206,17 +206,6 @@ paperclipai worktree init --from-data-dir ~/.paperclip
|
||||||
paperclipai worktree init --force
|
paperclipai worktree init --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
|
|
||||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
|
||||||
--name PAP-884-ai-commits-component \
|
|
||||||
--from-config ~/.paperclip/instances/default/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
|
||||||
|
|
||||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,10 @@ Public packages are discovered from:
|
||||||
|
|
||||||
- `packages/`
|
- `packages/`
|
||||||
- `server/`
|
- `server/`
|
||||||
- `ui/`
|
|
||||||
- `cli/`
|
- `cli/`
|
||||||
|
|
||||||
|
`ui/` is ignored because it is private.
|
||||||
|
|
||||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||||
|
|
||||||
- finds all public packages
|
- finds all public packages
|
||||||
|
|
@ -64,18 +65,6 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
|
||||||
|
|
||||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||||
|
|
||||||
## `@paperclipai/ui` packaging
|
|
||||||
|
|
||||||
The UI package publishes prebuilt static assets, not the source workspace.
|
|
||||||
|
|
||||||
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
|
|
||||||
|
|
||||||
- keeps the release-managed `name` and `version`
|
|
||||||
- publishes only `dist/`
|
|
||||||
- omits the source-only dependency graph from downstream installs
|
|
||||||
|
|
||||||
After packing or publishing, `postpack` restores the development manifest automatically.
|
|
||||||
|
|
||||||
## Version formats
|
## Version formats
|
||||||
|
|
||||||
Paperclip uses calendar versions:
|
Paperclip uses calendar versions:
|
||||||
|
|
@ -146,7 +135,6 @@ This is the fastest way to restore the default install path if a stable release
|
||||||
|
|
||||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||||
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
|
|
||||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||||
- [`doc/RELEASING.md`](RELEASING.md)
|
- [`doc/RELEASING.md`](RELEASING.md)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ At minimum that includes:
|
||||||
|
|
||||||
- `paperclipai`
|
- `paperclipai`
|
||||||
- `@paperclipai/server`
|
- `@paperclipai/server`
|
||||||
- `@paperclipai/ui`
|
|
||||||
- public packages under `packages/`
|
- public packages under `packages/`
|
||||||
|
|
||||||
### 2.1. In npm, open each package settings page
|
### 2.1. In npm, open each package settings page
|
||||||
|
|
|
||||||
33
doc/SPEC.md
33
doc/SPEC.md
|
|
@ -186,21 +186,17 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||||
|
|
||||||
### Execution Adapters
|
### Execution Adapters
|
||||||
|
|
||||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
|
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||||
|
|
||||||
| Adapter | Mechanism | Example |
|
| Adapter | Mechanism | Example |
|
||||||
| ---------------- | -------------------------- | -------------------------------------------------- |
|
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||||
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
|
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||||
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||||
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||||
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
|
|
||||||
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
|
||||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
|
||||||
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
|
||||||
|
|
||||||
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||||
|
|
||||||
### Adapter Interface
|
### Adapter Interface
|
||||||
|
|
||||||
|
|
@ -380,7 +376,7 @@ Flow:
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
| -------- | ------------------------------------------------------------ |
|
| -------- | ------------------------------------------------------------ |
|
||||||
| Frontend | React + Vite |
|
| Frontend | React + Vite |
|
||||||
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
|
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
|
||||||
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
||||||
| Auth | [Better Auth](https://www.better-auth.com/) |
|
| Auth | [Better Auth](https://www.better-auth.com/) |
|
||||||
|
|
||||||
|
|
@ -410,7 +406,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
||||||
|
|
||||||
### Work Artifacts
|
### Work Artifacts
|
||||||
|
|
||||||
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
|
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
|
||||||
|
|
||||||
### Open Questions
|
### Open Questions
|
||||||
|
|
||||||
|
|
@ -480,14 +476,15 @@ Each is a distinct page/route:
|
||||||
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
||||||
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
||||||
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
||||||
- [ ] **REST API** — full API for agent interaction (Express)
|
- [ ] **REST API** — full API for agent interaction (Hono)
|
||||||
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
||||||
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
||||||
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
||||||
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
|
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
|
||||||
|
|
||||||
### Not V1
|
### Not V1
|
||||||
|
|
||||||
|
- Template export/import
|
||||||
- Knowledge base - a future plugin
|
- Knowledge base - a future plugin
|
||||||
- Advanced governance models (hiring budgets, multi-member boards)
|
- Advanced governance models (hiring budgets, multi-member boards)
|
||||||
- Revenue/expense tracking beyond token costs - a future plugin
|
- Revenue/expense tracking beyond token costs - a future plugin
|
||||||
|
|
@ -512,7 +509,7 @@ Things Paperclip explicitly does **not** do:
|
||||||
- **Not a SaaS** — single-tenant, self-hosted
|
- **Not a SaaS** — single-tenant, self-hosted
|
||||||
- **Not opinionated about Agent implementation** — any language, any framework, any runtime
|
- **Not opinionated about Agent implementation** — any language, any framework, any runtime
|
||||||
- **Not automatically self-healing** — surfaces problems, doesn't silently fix them
|
- **Not automatically self-healing** — surfaces problems, doesn't silently fix them
|
||||||
- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments)
|
- **Does not manage work artifacts** — no repo management, no deployment, no file systems
|
||||||
- **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed
|
- **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed
|
||||||
- **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core.
|
- **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Agent Runtime Guide
|
# Agent Runtime Guide
|
||||||
|
|
||||||
Status: User-facing guide
|
Status: User-facing guide
|
||||||
Last updated: 2026-03-26
|
Last updated: 2026-02-17
|
||||||
Audience: Operators setting up and running agents in Paperclip
|
Audience: Operators setting up and running agents in Paperclip
|
||||||
|
|
||||||
## 1. What this system does
|
## 1. What this system does
|
||||||
|
|
@ -32,19 +32,14 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la
|
||||||
|
|
||||||
## 3.1 Adapter choice
|
## 3.1 Adapter choice
|
||||||
|
|
||||||
Built-in adapters:
|
Common choices:
|
||||||
|
|
||||||
- `claude_local`: runs your local `claude` CLI
|
- `claude_local`: runs your local `claude` CLI
|
||||||
- `codex_local`: runs your local `codex` CLI
|
- `codex_local`: runs your local `codex` CLI
|
||||||
- `opencode_local`: runs your local `opencode` CLI
|
|
||||||
- `hermes_local`: runs your local `hermes` CLI
|
|
||||||
- `cursor`: runs Cursor in background mode
|
|
||||||
- `pi_local`: runs an embedded Pi agent locally
|
|
||||||
- `openclaw_gateway`: connects to an OpenClaw gateway endpoint
|
|
||||||
- `process`: generic shell command adapter
|
- `process`: generic shell command adapter
|
||||||
- `http`: calls an external HTTP endpoint
|
- `http`: calls an external HTTP endpoint
|
||||||
|
|
||||||
For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine.
|
||||||
|
|
||||||
## 3.2 Runtime behavior
|
## 3.2 Runtime behavior
|
||||||
|
|
||||||
|
|
@ -74,8 +69,6 @@ You can set:
|
||||||
|
|
||||||
Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values.
|
Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values.
|
||||||
|
|
||||||
> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system.
|
|
||||||
|
|
||||||
## 4. Session resume behavior
|
## 4. Session resume behavior
|
||||||
|
|
||||||
Paperclip stores session IDs for resumable adapters.
|
Paperclip stores session IDs for resumable adapters.
|
||||||
|
|
@ -140,7 +133,7 @@ If the connection drops, the UI reconnects automatically.
|
||||||
|
|
||||||
If runs fail repeatedly:
|
If runs fail repeatedly:
|
||||||
|
|
||||||
1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in).
|
1. Check adapter command availability (`claude`/`codex` installed and logged in).
|
||||||
2. Verify `cwd` exists and is accessible.
|
2. Verify `cwd` exists and is accessible.
|
||||||
3. Inspect run error + stderr excerpt, then full log.
|
3. Inspect run error + stderr excerpt, then full log.
|
||||||
4. Confirm timeout is not too low.
|
4. Confirm timeout is not too low.
|
||||||
|
|
@ -173,9 +166,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r
|
||||||
|
|
||||||
## 10. Minimal setup checklist
|
## 10. Minimal setup checklist
|
||||||
|
|
||||||
1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`).
|
1. Choose adapter (`claude_local` or `codex_local`).
|
||||||
2. Set `cwd` to the target workspace (for local adapters).
|
2. Set `cwd` to the target workspace.
|
||||||
3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle.
|
3. Add bootstrap + normal prompt templates.
|
||||||
4. Configure heartbeat policy (timer and/or assignment wakeups).
|
4. Configure heartbeat policy (timer and/or assignment wakeups).
|
||||||
5. Trigger a manual wakeup.
|
5. Trigger a manual wakeup.
|
||||||
6. Confirm run succeeds and session/token usage is recorded.
|
6. Confirm run succeeds and session/token usage is recorded.
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@
|
||||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
|
|
@ -44,10 +44,5 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4",
|
"packageManager": "pnpm@9.15.4"
|
||||||
"pnpm": {
|
|
||||||
"patchedDependencies": {
|
|
||||||
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||||
effectiveInstructionsFilePath = combinedPath;
|
effectiveInstructionsFilePath = combinedPath;
|
||||||
|
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ Notes:
|
||||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||||
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
|
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
|
||||||
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
|
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
|
||||||
- Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances/<id>/companies/<companyId>/codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead.
|
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
|
||||||
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
|
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
|
||||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
- 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.
|
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
|
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js";
|
||||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -135,8 +135,8 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCodexSkillsDir(codexHome: string): string {
|
function resolveCodexWorkspaceSkillsDir(cwd: string): string {
|
||||||
return path.join(codexHome, "skills");
|
return path.join(cwd, ".agents", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
|
|
@ -157,7 +157,7 @@ export async function ensureCodexSkillsInjected(
|
||||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
|
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
if (skillsEntries.length === 0) return;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = options.skillsHome ?? resolveCodexSkillsDir(resolveSharedCodexHomeDir());
|
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd());
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const linkSkill = options.linkSkill;
|
const linkSkill = options.linkSkill;
|
||||||
for (const entry of skillsEntries) {
|
for (const entry of skillsEntries) {
|
||||||
|
|
@ -273,13 +273,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
||||||
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
||||||
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
||||||
// Inject skills into the same CODEX_HOME that Codex will actually run with
|
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
|
||||||
// (managed home in the default case, or an explicit override from adapter config).
|
|
||||||
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
|
|
||||||
await ensureCodexSkillsInjected(
|
await ensureCodexSkillsInjected(
|
||||||
onLog,
|
onLog,
|
||||||
{
|
{
|
||||||
skillsHome: codexSkillsDir,
|
skillsHome: codexWorkspaceSkillsDir,
|
||||||
skillsEntries: codexSkillEntries,
|
skillsEntries: codexSkillEntries,
|
||||||
desiredSkillNames,
|
desiredSkillNames,
|
||||||
},
|
},
|
||||||
|
|
@ -417,6 +415,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
instructionsChars = instructionsPrefix.length;
|
instructionsChars = instructionsPrefix.length;
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,8 @@ function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string
|
||||||
return { email: null, planType: null };
|
return { email: null, planType: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readCodexAuthInfo(codexHome?: string): Promise<CodexAuthInfo | null> {
|
export async function readCodexAuthInfo(): Promise<CodexAuthInfo | null> {
|
||||||
const authPath = path.join(codexHome ?? codexHomeDir(), "auth.json");
|
const authPath = path.join(codexHomeDir(), "auth.json");
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
raw = await fs.readFile(authPath, "utf8");
|
raw = await fs.readFile(authPath, "utf8");
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ async function buildCodexSkillSnapshot(
|
||||||
sourcePath: entry.source,
|
sourcePath: entry.source,
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
detail: desiredSet.has(entry.key)
|
detail: desiredSet.has(entry.key)
|
||||||
? "Will be linked into the effective CODEX_HOME/skills/ directory on the next run."
|
? "Will be linked into the workspace .agents/skills directory on the next run."
|
||||||
: null,
|
: null,
|
||||||
required: Boolean(entry.required),
|
required: Boolean(entry.required),
|
||||||
requiredReason: entry.requiredReason ?? null,
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { parseCodexJsonl } from "./parse.js";
|
import { parseCodexJsonl } from "./parse.js";
|
||||||
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
|
|
||||||
|
|
||||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
if (checks.some((check) => check.level === "error")) return "fail";
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
|
@ -109,23 +108,12 @@ export async function testEnvironment(
|
||||||
detail: `Detected in ${source}.`,
|
detail: `Detected in ${source}.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
|
checks.push({
|
||||||
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
|
code: "codex_openai_api_key_missing",
|
||||||
if (codexAuth) {
|
level: "warn",
|
||||||
checks.push({
|
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
|
||||||
code: "codex_native_auth_present",
|
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
|
||||||
level: "info",
|
});
|
||||||
message: "Codex is authenticated via its own auth configuration.",
|
|
||||||
detail: codexAuth.email ? `Logged in as ${codexAuth.email}.` : `Credentials found in ${path.join(codexHome ?? codexHomeDir(), "auth.json")}.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "codex_openai_api_key_missing",
|
|
||||||
level: "warn",
|
|
||||||
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
|
|
||||||
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or run `codex auth` to log in.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRunProbe =
|
const canRunProbe =
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
instructionsChars = instructionsPrefix.length;
|
instructionsChars = instructionsPrefix.length;
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import {
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||||
import { parseCursorJsonl } from "./parse.js";
|
import { parseCursorJsonl } from "./parse.js";
|
||||||
|
|
@ -51,41 +49,6 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
|
||||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CursorAuthInfo {
|
|
||||||
email: string | null;
|
|
||||||
displayName: string | null;
|
|
||||||
userId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cursorConfigPath(cursorHome?: string): string {
|
|
||||||
return path.join(cursorHome ?? path.join(os.homedir(), ".cursor"), "cli-config.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readCursorAuthInfo(cursorHome?: string): Promise<CursorAuthInfo | null> {
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await fs.readFile(cursorConfigPath(cursorHome), "utf8");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof parsed !== "object" || parsed === null) return null;
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
const authInfo = obj.authInfo;
|
|
||||||
if (typeof authInfo !== "object" || authInfo === null) return null;
|
|
||||||
const info = authInfo as Record<string, unknown>;
|
|
||||||
const email = typeof info.email === "string" && info.email.trim().length > 0 ? info.email.trim() : null;
|
|
||||||
const displayName = typeof info.displayName === "string" && info.displayName.trim().length > 0 ? info.displayName.trim() : null;
|
|
||||||
const userId = typeof info.userId === "number" ? info.userId : null;
|
|
||||||
if (!email && !displayName && userId == null) return null;
|
|
||||||
return { email, displayName, userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURSOR_AUTH_REQUIRED_RE =
|
const CURSOR_AUTH_REQUIRED_RE =
|
||||||
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
|
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
|
||||||
|
|
||||||
|
|
@ -146,25 +109,12 @@ export async function testEnvironment(
|
||||||
detail: `Detected in ${source}.`,
|
detail: `Detected in ${source}.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
|
checks.push({
|
||||||
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
|
code: "cursor_api_key_missing",
|
||||||
if (cursorAuth) {
|
level: "warn",
|
||||||
checks.push({
|
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
|
||||||
code: "cursor_native_auth_present",
|
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
|
||||||
level: "info",
|
});
|
||||||
message: "Cursor is authenticated via `agent login`.",
|
|
||||||
detail: cursorAuth.email
|
|
||||||
? `Logged in as ${cursorAuth.email}.`
|
|
||||||
: `Credentials found in ${cursorConfigPath(cursorHome)}.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "cursor_api_key_missing",
|
|
||||||
level: "warn",
|
|
||||||
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
|
|
||||||
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRunProbe =
|
const canRunProbe =
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`${instructionsContents}\n\n` +
|
`${instructionsContents}\n\n` +
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ Core fields:
|
||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||||
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||||
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||||
- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs
|
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- command (string, optional): defaults to "opencode"
|
- command (string, optional): defaults to "opencode"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
|
@ -38,10 +37,4 @@ Notes:
|
||||||
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
|
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
|
||||||
- Runs are executed with: opencode run --format json ...
|
- Runs are executed with: opencode run --format json ...
|
||||||
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||||
- The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \
|
|
||||||
writing an opencode.json config file into the project working directory. Model \
|
|
||||||
selection is passed via the --model CLI flag instead.
|
|
||||||
- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \
|
|
||||||
runtime config with \`permission.external_directory=allow\` so headless runs do \
|
|
||||||
not stall on approval prompts.
|
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
|
@ -170,247 +169,238 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
}
|
}
|
||||||
// Prevent OpenCode from writing an opencode.json config file into the
|
|
||||||
// project working directory (which would pollute the git repo). Model
|
|
||||||
// selection is already handled via the --model CLI flag. Set after the
|
|
||||||
// envConfig loop so user overrides cannot disable this guard.
|
|
||||||
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
|
||||||
if (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
const runtimeEnv = Object.fromEntries(
|
||||||
try {
|
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||||
const runtimeEnv = Object.fromEntries(
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
),
|
||||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
);
|
||||||
),
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
);
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
model,
|
model,
|
||||||
command,
|
command,
|
||||||
|
cwd,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 0 &&
|
||||||
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||||
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
|
if (runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
const resolvedInstructionsFilePath = instructionsFilePath
|
||||||
|
? path.resolve(cwd, instructionsFilePath)
|
||||||
|
: "";
|
||||||
|
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
||||||
|
let instructionsPrefix = "";
|
||||||
|
if (resolvedInstructionsFilePath) {
|
||||||
|
try {
|
||||||
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||||
|
instructionsPrefix =
|
||||||
|
`${instructionsContents}\n\n` +
|
||||||
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||||
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandNotes = (() => {
|
||||||
|
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||||
|
if (instructionsPrefix.length > 0) {
|
||||||
|
return [
|
||||||
|
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||||
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||||
|
const templateData = {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||||
|
const renderedBootstrapPrompt =
|
||||||
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||||
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||||
|
: "";
|
||||||
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
|
const prompt = joinPromptSections([
|
||||||
|
instructionsPrefix,
|
||||||
|
renderedBootstrapPrompt,
|
||||||
|
sessionHandoffNote,
|
||||||
|
renderedPrompt,
|
||||||
|
]);
|
||||||
|
const promptMetrics = {
|
||||||
|
promptChars: prompt.length,
|
||||||
|
instructionsChars: instructionsPrefix.length,
|
||||||
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
|
const args = ["run", "--format", "json"];
|
||||||
|
if (resumeSessionId) args.push("--session", resumeSessionId);
|
||||||
|
if (model) args.push("--model", model);
|
||||||
|
if (variant) args.push("--variant", variant);
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
|
const args = buildArgs(resumeSessionId);
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "opencode_local",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandNotes,
|
||||||
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
prompt,
|
||||||
|
promptMetrics,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
|
stdin: prompt,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onSpawn,
|
||||||
|
onLog,
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
proc,
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
rawStderr: proc.stderr,
|
||||||
const extraArgs = (() => {
|
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
||||||
const canResumeSession =
|
|
||||||
runtimeSessionId.length > 0 &&
|
|
||||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
|
||||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
||||||
const resolvedInstructionsFilePath = instructionsFilePath
|
|
||||||
? path.resolve(cwd, instructionsFilePath)
|
|
||||||
: "";
|
|
||||||
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
|
||||||
let instructionsPrefix = "";
|
|
||||||
if (resolvedInstructionsFilePath) {
|
|
||||||
try {
|
|
||||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
|
||||||
instructionsPrefix =
|
|
||||||
`${instructionsContents}\n\n` +
|
|
||||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
||||||
} catch (err) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandNotes = (() => {
|
|
||||||
const notes = [...preparedRuntimeConfig.notes];
|
|
||||||
if (!resolvedInstructionsFilePath) return notes;
|
|
||||||
if (instructionsPrefix.length > 0) {
|
|
||||||
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
|
|
||||||
notes.push(
|
|
||||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
||||||
);
|
|
||||||
return notes;
|
|
||||||
}
|
|
||||||
notes.push(
|
|
||||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
||||||
);
|
|
||||||
return notes;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
||||||
const templateData = {
|
|
||||||
agentId: agent.id,
|
|
||||||
companyId: agent.companyId,
|
|
||||||
runId,
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
};
|
|
||||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
|
||||||
const renderedBootstrapPrompt =
|
|
||||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
||||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
||||||
: "";
|
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
||||||
const prompt = joinPromptSections([
|
|
||||||
instructionsPrefix,
|
|
||||||
renderedBootstrapPrompt,
|
|
||||||
sessionHandoffNote,
|
|
||||||
renderedPrompt,
|
|
||||||
]);
|
|
||||||
const promptMetrics = {
|
|
||||||
promptChars: prompt.length,
|
|
||||||
instructionsChars: instructionsPrefix.length,
|
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
const toResult = (
|
||||||
const args = ["run", "--format", "json"];
|
attempt: {
|
||||||
if (resumeSessionId) args.push("--session", resumeSessionId);
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||||
if (model) args.push("--model", model);
|
rawStderr: string;
|
||||||
if (variant) args.push("--variant", variant);
|
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
},
|
||||||
return args;
|
clearSessionOnMissingSession = false,
|
||||||
};
|
): AdapterExecutionResult => {
|
||||||
|
if (attempt.proc.timedOut) {
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
|
||||||
const args = buildArgs(resumeSessionId);
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "opencode_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandNotes,
|
|
||||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
|
||||||
env: redactEnvForLogs(preparedRuntimeConfig.env),
|
|
||||||
prompt,
|
|
||||||
promptMetrics,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env: runtimeEnv,
|
|
||||||
stdin: prompt,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onSpawn,
|
|
||||||
onLog,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
proc,
|
exitCode: attempt.proc.exitCode,
|
||||||
rawStderr: proc.stderr,
|
|
||||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toResult = (
|
|
||||||
attempt: {
|
|
||||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
|
||||||
rawStderr: string;
|
|
||||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
|
||||||
},
|
|
||||||
clearSessionOnMissingSession = false,
|
|
||||||
): AdapterExecutionResult => {
|
|
||||||
if (attempt.proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: attempt.proc.exitCode,
|
|
||||||
signal: attempt.proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
clearSession: clearSessionOnMissingSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSessionId =
|
|
||||||
attempt.parsed.sessionId ??
|
|
||||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
|
||||||
const resolvedSessionParams = resolvedSessionId
|
|
||||||
? ({
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
cwd,
|
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
||||||
} as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
||||||
const rawExitCode = attempt.proc.exitCode;
|
|
||||||
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
|
||||||
const fallbackErrorMessage =
|
|
||||||
parsedError ||
|
|
||||||
stderrLine ||
|
|
||||||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
|
||||||
const modelId = model || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: synthesizedExitCode,
|
|
||||||
signal: attempt.proc.signal,
|
signal: attempt.proc.signal,
|
||||||
timedOut: false,
|
timedOut: true,
|
||||||
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
usage: {
|
clearSession: clearSessionOnMissingSession,
|
||||||
inputTokens: attempt.parsed.usage.inputTokens,
|
|
||||||
outputTokens: attempt.parsed.usage.outputTokens,
|
|
||||||
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
|
||||||
},
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
sessionParams: resolvedSessionParams,
|
|
||||||
sessionDisplayId: resolvedSessionId,
|
|
||||||
provider: parseModelProvider(modelId),
|
|
||||||
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
|
||||||
model: modelId,
|
|
||||||
billingType: "unknown",
|
|
||||||
costUsd: attempt.parsed.costUsd,
|
|
||||||
resultJson: {
|
|
||||||
stdout: attempt.proc.stdout,
|
|
||||||
stderr: attempt.proc.stderr,
|
|
||||||
},
|
|
||||||
summary: attempt.parsed.summary,
|
|
||||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
|
||||||
const initialFailed =
|
|
||||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
|
||||||
if (
|
|
||||||
sessionId &&
|
|
||||||
initialFailed &&
|
|
||||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
|
||||||
) {
|
|
||||||
await onLog(
|
|
||||||
"stdout",
|
|
||||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
||||||
);
|
|
||||||
const retry = await runAttempt(null);
|
|
||||||
return toResult(retry, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return toResult(initial);
|
const resolvedSessionId =
|
||||||
} finally {
|
attempt.parsed.sessionId ??
|
||||||
await preparedRuntimeConfig.cleanup();
|
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const rawExitCode = attempt.proc.exitCode;
|
||||||
|
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
||||||
|
const modelId = model || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: synthesizedExitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||||
|
usage: {
|
||||||
|
inputTokens: attempt.parsed.usage.inputTokens,
|
||||||
|
outputTokens: attempt.parsed.usage.outputTokens,
|
||||||
|
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||||
|
},
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
|
provider: parseModelProvider(modelId),
|
||||||
|
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
||||||
|
model: modelId,
|
||||||
|
billingType: "unknown",
|
||||||
|
costUsd: attempt.parsed.costUsd,
|
||||||
|
resultJson: {
|
||||||
|
stdout: attempt.proc.stdout,
|
||||||
|
stderr: attempt.proc.stderr,
|
||||||
|
},
|
||||||
|
summary: attempt.parsed.summary,
|
||||||
|
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId);
|
||||||
|
const initialFailed =
|
||||||
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
initialFailed &&
|
||||||
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return toResult(initial);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,7 @@ export async function discoverOpenCodeModels(input: {
|
||||||
// /etc/passwd entry (e.g. `docker run --user 1234` with a minimal
|
// /etc/passwd entry (e.g. `docker run --user 1234` with a minimal
|
||||||
// image). Fall back to process.env.HOME.
|
// image). Fall back to process.env.HOME.
|
||||||
}
|
}
|
||||||
// Prevent OpenCode from writing an opencode.json into the working directory.
|
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}) }));
|
||||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}), OPENCODE_DISABLE_PROJECT_CONFIG: "true" }));
|
|
||||||
|
|
||||||
const result = await runChildProcess(
|
const result = await runChildProcess(
|
||||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
|
||||||
|
|
||||||
const cleanupPaths = new Set<string>();
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(
|
|
||||||
[...cleanupPaths].map(async (filepath) => {
|
|
||||||
await fs.rm(filepath, { recursive: true, force: true });
|
|
||||||
cleanupPaths.delete(filepath);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function makeConfigHome(initialConfig?: Record<string, unknown>) {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-"));
|
|
||||||
cleanupPaths.add(root);
|
|
||||||
const configDir = path.join(root, "opencode");
|
|
||||||
await fs.mkdir(configDir, { recursive: true });
|
|
||||||
if (initialConfig) {
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(configDir, "opencode.json"),
|
|
||||||
`${JSON.stringify(initialConfig, null, 2)}\n`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("prepareOpenCodeRuntimeConfig", () => {
|
|
||||||
it("injects an external_directory allow rule by default", async () => {
|
|
||||||
const configHome = await makeConfigHome({
|
|
||||||
permission: {
|
|
||||||
read: "allow",
|
|
||||||
},
|
|
||||||
theme: "system",
|
|
||||||
});
|
|
||||||
|
|
||||||
const prepared = await prepareOpenCodeRuntimeConfig({
|
|
||||||
env: { XDG_CONFIG_HOME: configHome },
|
|
||||||
config: {},
|
|
||||||
});
|
|
||||||
cleanupPaths.add(prepared.env.XDG_CONFIG_HOME);
|
|
||||||
|
|
||||||
expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome);
|
|
||||||
const runtimeConfig = JSON.parse(
|
|
||||||
await fs.readFile(
|
|
||||||
path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
expect(runtimeConfig).toMatchObject({
|
|
||||||
theme: "system",
|
|
||||||
permission: {
|
|
||||||
read: "allow",
|
|
||||||
external_directory: "allow",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prepared.cleanup();
|
|
||||||
cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME);
|
|
||||||
await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects explicit opt-out", async () => {
|
|
||||||
const configHome = await makeConfigHome();
|
|
||||||
const prepared = await prepareOpenCodeRuntimeConfig({
|
|
||||||
env: { XDG_CONFIG_HOME: configHome },
|
|
||||||
config: { dangerouslySkipPermissions: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome });
|
|
||||||
expect(prepared.notes).toEqual([]);
|
|
||||||
await prepared.cleanup();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { asBoolean } from "@paperclipai/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
type PreparedOpenCodeRuntimeConfig = {
|
|
||||||
env: Record<string, string>;
|
|
||||||
notes: string[];
|
|
||||||
cleanup: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveXdgConfigHome(env: Record<string, string>): string {
|
|
||||||
return (
|
|
||||||
(typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) ||
|
|
||||||
(typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) ||
|
|
||||||
path.join(os.homedir(), ".config")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonObject(filepath: string): Promise<Record<string, unknown>> {
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(filepath, "utf8");
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return isPlainObject(parsed) ? parsed : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prepareOpenCodeRuntimeConfig(input: {
|
|
||||||
env: Record<string, string>;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
}): Promise<PreparedOpenCodeRuntimeConfig> {
|
|
||||||
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
|
|
||||||
if (!skipPermissions) {
|
|
||||||
return {
|
|
||||||
env: input.env,
|
|
||||||
notes: [],
|
|
||||||
cleanup: async () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
|
|
||||||
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
|
|
||||||
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
|
|
||||||
const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json");
|
|
||||||
|
|
||||||
await fs.mkdir(runtimeConfigDir, { recursive: true });
|
|
||||||
try {
|
|
||||||
await fs.cp(sourceConfigDir, runtimeConfigDir, {
|
|
||||||
recursive: true,
|
|
||||||
force: true,
|
|
||||||
errorOnExist: false,
|
|
||||||
dereference: false,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingConfig = await readJsonObject(runtimeConfigPath);
|
|
||||||
const existingPermission = isPlainObject(existingConfig.permission)
|
|
||||||
? existingConfig.permission
|
|
||||||
: {};
|
|
||||||
const nextConfig = {
|
|
||||||
...existingConfig,
|
|
||||||
permission: {
|
|
||||||
...existingPermission,
|
|
||||||
external_directory: "allow",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
|
|
||||||
|
|
||||||
return {
|
|
||||||
env: {
|
|
||||||
...input.env,
|
|
||||||
XDG_CONFIG_HOME: runtimeConfigHome,
|
|
||||||
},
|
|
||||||
notes: [
|
|
||||||
"Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.",
|
|
||||||
],
|
|
||||||
cleanup: async () => {
|
|
||||||
await fs.rm(runtimeConfigHome, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type {
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
asBoolean,
|
|
||||||
asString,
|
asString,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
parseObject,
|
parseObject,
|
||||||
|
|
@ -15,7 +14,6 @@ import {
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
import { parseOpenCodeJsonl } from "./parse.js";
|
import { parseOpenCodeJsonl } from "./parse.js";
|
||||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
|
||||||
|
|
||||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
if (checks.some((check) => check.level === "error")) return "fail";
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
|
@ -92,238 +90,224 @@ export async function testEnvironment(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent OpenCode from writing an opencode.json into the working directory.
|
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||||
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
|
|
||||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
|
||||||
if (asBoolean(config.dangerouslySkipPermissions, true)) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_headless_permissions_enabled",
|
|
||||||
level: "info",
|
|
||||||
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));
|
|
||||||
|
|
||||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||||
if (cwdInvalid) {
|
if (cwdInvalid) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_command_skipped",
|
||||||
|
level: "warn",
|
||||||
|
message: "Skipped command check because working directory validation failed.",
|
||||||
|
detail: command,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_skipped",
|
code: "opencode_command_resolvable",
|
||||||
level: "warn",
|
level: "info",
|
||||||
message: "Skipped command check because working directory validation failed.",
|
message: `Command is executable: ${command}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_command_unresolvable",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
detail: command,
|
detail: command,
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
try {
|
}
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
const canRunProbe =
|
||||||
|
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||||
|
|
||||||
|
let modelValidationPassed = false;
|
||||||
|
const configuredModel = asString(config.model, "").trim();
|
||||||
|
|
||||||
|
if (canRunProbe && configuredModel) {
|
||||||
|
try {
|
||||||
|
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||||
|
if (discovered.length > 0) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_resolvable",
|
code: "opencode_models_discovered",
|
||||||
level: "info",
|
level: "info",
|
||||||
message: `Command is executable: ${command}`,
|
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} else {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_unresolvable",
|
code: "opencode_models_empty",
|
||||||
level: "error",
|
level: "error",
|
||||||
message: err instanceof Error ? err.message : "Command is not executable",
|
message: "OpenCode returned no models.",
|
||||||
detail: command,
|
hint: "Run `opencode models` and verify provider authentication.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
|
level: "warn",
|
||||||
|
message: "The configured model was not found by the provider.",
|
||||||
|
detail: errMsg,
|
||||||
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_models_discovery_failed",
|
||||||
|
level: "error",
|
||||||
|
message: errMsg || "OpenCode model discovery failed.",
|
||||||
|
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (canRunProbe && !configuredModel) {
|
||||||
const canRunProbe =
|
try {
|
||||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||||
|
if (discovered.length > 0) {
|
||||||
let modelValidationPassed = false;
|
checks.push({
|
||||||
const configuredModel = asString(config.model, "").trim();
|
code: "opencode_models_discovered",
|
||||||
|
level: "info",
|
||||||
if (canRunProbe && configuredModel) {
|
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||||
try {
|
});
|
||||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
|
||||||
if (discovered.length > 0) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_models_discovered",
|
|
||||||
level: "info",
|
|
||||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_models_empty",
|
|
||||||
level: "error",
|
|
||||||
message: "OpenCode returned no models.",
|
|
||||||
hint: "Run `opencode models` and verify provider authentication.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
|
||||||
level: "warn",
|
|
||||||
message: "The configured model was not found by the provider.",
|
|
||||||
detail: errMsg,
|
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_models_discovery_failed",
|
|
||||||
level: "error",
|
|
||||||
message: errMsg || "OpenCode model discovery failed.",
|
|
||||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (canRunProbe && !configuredModel) {
|
} catch (err) {
|
||||||
try {
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||||
if (discovered.length > 0) {
|
checks.push({
|
||||||
checks.push({
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
code: "opencode_models_discovered",
|
level: "warn",
|
||||||
level: "info",
|
message: "The configured model was not found by the provider.",
|
||||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
detail: errMsg,
|
||||||
});
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
}
|
});
|
||||||
} catch (err) {
|
} else {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
checks.push({
|
||||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
code: "opencode_models_discovery_failed",
|
||||||
checks.push({
|
level: "warn",
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
||||||
level: "warn",
|
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||||
message: "The configured model was not found by the provider.",
|
});
|
||||||
detail: errMsg,
|
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_models_discovery_failed",
|
|
||||||
level: "warn",
|
|
||||||
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
|
||||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||||
if (!configuredModel && !modelUnavailable) {
|
if (!configuredModel && !modelUnavailable) {
|
||||||
// No model configured – skip model requirement if no model-related checks exist
|
// No model configured – skip model requirement if no model-related checks exist
|
||||||
} else if (configuredModel && canRunProbe) {
|
} else if (configuredModel && canRunProbe) {
|
||||||
try {
|
try {
|
||||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
model: configuredModel,
|
model: configuredModel,
|
||||||
command,
|
command,
|
||||||
|
cwd,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_model_configured",
|
||||||
|
level: "info",
|
||||||
|
message: `Configured model: ${configuredModel}`,
|
||||||
|
});
|
||||||
|
modelValidationPassed = true;
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_model_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
||||||
|
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canRunProbe && modelValidationPassed) {
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
const variant = asString(config.variant, "").trim();
|
||||||
|
const probeModel = configuredModel;
|
||||||
|
|
||||||
|
const args = ["run", "--format", "json"];
|
||||||
|
args.push("--model", probeModel);
|
||||||
|
if (variant) args.push("--variant", variant);
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const probe = await runChildProcess(
|
||||||
|
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
{
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
});
|
timeoutSec: 60,
|
||||||
|
graceSec: 5,
|
||||||
|
stdin: "Respond with hello.",
|
||||||
|
onLog: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||||
|
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||||
|
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||||
|
|
||||||
|
if (probe.timedOut) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_model_configured",
|
code: "opencode_hello_probe_timed_out",
|
||||||
level: "info",
|
level: "warn",
|
||||||
message: `Configured model: ${configuredModel}`,
|
message: "OpenCode hello probe timed out.",
|
||||||
|
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||||
});
|
});
|
||||||
modelValidationPassed = true;
|
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||||
} catch (err) {
|
const summary = parsed.summary.trim();
|
||||||
|
const hasHello = /\bhello\b/i.test(summary);
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_model_invalid",
|
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
||||||
level: "error",
|
level: hasHello ? "info" : "warn",
|
||||||
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
message: hasHello
|
||||||
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
? "OpenCode hello probe succeeded."
|
||||||
|
: "OpenCode probe ran but did not return `hello` as expected.",
|
||||||
|
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||||
|
...(hasHello
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
||||||
}
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_model_unavailable",
|
||||||
if (canRunProbe && modelValidationPassed) {
|
level: "warn",
|
||||||
const extraArgs = (() => {
|
message: "The configured model was not found by the provider.",
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
...(detail ? { detail } : {}),
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||||
return asStringArray(config.args);
|
});
|
||||||
})();
|
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||||
const variant = asString(config.variant, "").trim();
|
checks.push({
|
||||||
const probeModel = configuredModel;
|
code: "opencode_hello_probe_auth_required",
|
||||||
|
level: "warn",
|
||||||
const args = ["run", "--format", "json"];
|
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||||
args.push("--model", probeModel);
|
...(detail ? { detail } : {}),
|
||||||
if (variant) args.push("--variant", variant);
|
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
});
|
||||||
|
} else {
|
||||||
try {
|
|
||||||
const probe = await runChildProcess(
|
|
||||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
{
|
|
||||||
cwd,
|
|
||||||
env: runtimeEnv,
|
|
||||||
timeoutSec: 60,
|
|
||||||
graceSec: 5,
|
|
||||||
stdin: "Respond with hello.",
|
|
||||||
onLog: async () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
|
||||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
|
||||||
|
|
||||||
if (probe.timedOut) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_timed_out",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenCode hello probe timed out.",
|
|
||||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
|
||||||
});
|
|
||||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
|
||||||
const summary = parsed.summary.trim();
|
|
||||||
const hasHello = /\bhello\b/i.test(summary);
|
|
||||||
checks.push({
|
|
||||||
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
|
||||||
level: hasHello ? "info" : "warn",
|
|
||||||
message: hasHello
|
|
||||||
? "OpenCode hello probe succeeded."
|
|
||||||
: "OpenCode probe ran but did not return `hello` as expected.",
|
|
||||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
|
||||||
...(hasHello
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_model_unavailable",
|
|
||||||
level: "warn",
|
|
||||||
message: "The configured model was not found by the provider.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
|
||||||
});
|
|
||||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_auth_required",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_hello_probe_failed",
|
|
||||||
level: "error",
|
|
||||||
message: "OpenCode hello probe failed.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_hello_probe_failed",
|
code: "opencode_hello_probe_failed",
|
||||||
level: "error",
|
level: "error",
|
||||||
message: "OpenCode hello probe failed.",
|
message: "OpenCode hello probe failed.",
|
||||||
detail: err instanceof Error ? err.message : String(err),
|
...(detail ? { detail } : {}),
|
||||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_hello_probe_failed",
|
||||||
|
level: "error",
|
||||||
|
message: "OpenCode hello probe failed.",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await preparedRuntimeConfig.cleanup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||||
if (v.model) ac.model = v.model;
|
if (v.model) ac.model = v.model;
|
||||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
|
||||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||||
ac.timeoutSec = 0;
|
ac.timeoutSec = 0;
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
instructionsReadFailed = true;
|
instructionsReadFailed = true;
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
|
@ -326,9 +330,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const buildArgs = (sessionFile: string): string[] => {
|
const buildArgs = (sessionFile: string): string[] => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
// Use JSON mode for structured output with print mode (non-interactive)
|
// Use RPC mode for proper lifecycle management (waits for agent completion)
|
||||||
args.push("--mode", "json");
|
args.push("--mode", "rpc");
|
||||||
args.push("-p"); // Non-interactive mode: process prompt and exit
|
|
||||||
|
|
||||||
// Use --append-system-prompt to extend Pi's default system prompt
|
// Use --append-system-prompt to extend Pi's default system prompt
|
||||||
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
||||||
|
|
@ -344,13 +347,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
args.push("--skill", PI_AGENT_SKILLS_DIR);
|
args.push("--skill", PI_AGENT_SKILLS_DIR);
|
||||||
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
|
||||||
// Add the user prompt as the last argument
|
|
||||||
args.push(userPrompt);
|
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildRpcStdin = (): string => {
|
||||||
|
// Send the prompt as an RPC command
|
||||||
|
const promptCommand = {
|
||||||
|
type: "prompt",
|
||||||
|
message: userPrompt,
|
||||||
|
};
|
||||||
|
return JSON.stringify(promptCommand) + "\n";
|
||||||
|
};
|
||||||
|
|
||||||
const runAttempt = async (sessionFile: string) => {
|
const runAttempt = async (sessionFile: string) => {
|
||||||
const args = buildArgs(sessionFile);
|
const args = buildArgs(sessionFile);
|
||||||
if (onMeta) {
|
if (onMeta) {
|
||||||
|
|
@ -397,6 +406,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
graceSec,
|
graceSec,
|
||||||
onSpawn,
|
onSpawn,
|
||||||
onLog: bufferedOnLog,
|
onLog: bufferedOnLog,
|
||||||
|
stdin: buildRpcStdin(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Flush any remaining buffer content
|
// Flush any remaining buffer content
|
||||||
|
|
|
||||||
|
|
@ -131,9 +131,7 @@ export async function discoverPiModels(input: {
|
||||||
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
|
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pi outputs model list to stderr, but fall back to stdout for older versions
|
return sortModels(dedupeModels(parseModelsOutput(result.stdout)));
|
||||||
const output = result.stderr || result.stdout;
|
|
||||||
return sortModels(dedupeModels(parseModelsOutput(output)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEnv(input: unknown): Record<string, string> {
|
function normalizeEnv(input: unknown): Record<string, string> {
|
||||||
|
|
|
||||||
|
|
@ -17,39 +17,19 @@ function asString(value: unknown, fallback = ""): string {
|
||||||
return typeof value === "string" ? value : fallback;
|
return typeof value === "string" ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTextContent(content: string | Array<{ type: string; text?: string; thinking?: string }>): { text: string; thinking: string } {
|
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||||
if (typeof content === "string") return { text: content, thinking: "" };
|
if (typeof content === "string") return content;
|
||||||
if (!Array.isArray(content)) return { text: "", thinking: "" };
|
if (!Array.isArray(content)) return "";
|
||||||
|
return content
|
||||||
let text = "";
|
.filter((c) => c.type === "text" && c.text)
|
||||||
let thinking = "";
|
.map((c) => c.text!)
|
||||||
|
.join("");
|
||||||
for (const c of content) {
|
|
||||||
if (c.type === "text" && c.text) {
|
|
||||||
text += c.text;
|
|
||||||
}
|
|
||||||
if (c.type === "thinking" && c.thinking) {
|
|
||||||
thinking += c.thinking;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text, thinking };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track pending tool calls for proper toolUseId matching
|
|
||||||
let pendingToolCalls = new Map<string, { toolName: string; args: unknown }>();
|
|
||||||
|
|
||||||
export function resetParserState(): void {
|
|
||||||
pendingToolCalls.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
// Non-JSON line, treat as raw stdout
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) return [];
|
|
||||||
return [{ kind: "stdout", ts, text: trimmed }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
const type = asString(parsed.type);
|
||||||
|
|
@ -61,64 +41,16 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
|
||||||
// Agent lifecycle
|
// Agent lifecycle
|
||||||
if (type === "agent_start") {
|
if (type === "agent_start") {
|
||||||
return [{ kind: "system", ts, text: "🚀 Pi agent started" }];
|
return [{ kind: "system", ts, text: "Pi agent started" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "agent_end") {
|
if (type === "agent_end") {
|
||||||
const entries: TranscriptEntry[] = [];
|
return [{ kind: "system", ts, text: "Pi agent finished" }];
|
||||||
|
|
||||||
// Extract final message from messages array if available
|
|
||||||
const messages = parsed.messages as Array<Record<string, unknown>> | undefined;
|
|
||||||
if (messages && messages.length > 0) {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
if (lastMessage?.role === "assistant") {
|
|
||||||
const content = lastMessage.content as string | Array<{ type: string; text?: string; thinking?: string }>;
|
|
||||||
const { text, thinking } = extractTextContent(content);
|
|
||||||
|
|
||||||
if (thinking) {
|
|
||||||
entries.push({ kind: "thinking", ts, text: thinking });
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
entries.push({ kind: "assistant", ts, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract usage
|
|
||||||
const usage = asRecord(lastMessage.usage);
|
|
||||||
if (usage) {
|
|
||||||
const inputTokens = (usage.inputTokens ?? usage.input ?? 0) as number;
|
|
||||||
const outputTokens = (usage.outputTokens ?? usage.output ?? 0) as number;
|
|
||||||
const cachedTokens = (usage.cacheRead ?? usage.cachedInputTokens ?? 0) as number;
|
|
||||||
const costRecord = asRecord(usage.cost);
|
|
||||||
const costUsd = (costRecord?.total ?? usage.costUsd ?? 0) as number;
|
|
||||||
|
|
||||||
if (inputTokens > 0 || outputTokens > 0) {
|
|
||||||
entries.push({
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text: "Run completed",
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
cachedTokens,
|
|
||||||
costUsd,
|
|
||||||
subtype: "end",
|
|
||||||
isError: false,
|
|
||||||
errors: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
entries.push({ kind: "system", ts, text: "✅ Pi agent finished" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn lifecycle
|
// Turn lifecycle
|
||||||
if (type === "turn_start") {
|
if (type === "turn_start") {
|
||||||
return []; // Skip noisy lifecycle events
|
return [{ kind: "system", ts, text: "Turn started" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "turn_end") {
|
if (type === "turn_end") {
|
||||||
|
|
@ -128,21 +60,16 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const entries: TranscriptEntry[] = [];
|
const entries: TranscriptEntry[] = [];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>;
|
const content = message.content as string | Array<{ type: string; text?: string }>;
|
||||||
const { text, thinking } = extractTextContent(content);
|
const text = extractTextContent(content);
|
||||||
|
|
||||||
if (thinking) {
|
|
||||||
entries.push({ kind: "thinking", ts, text: thinking });
|
|
||||||
}
|
|
||||||
if (text) {
|
if (text) {
|
||||||
entries.push({ kind: "assistant", ts, text });
|
entries.push({ kind: "assistant", ts, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process tool results - match with pending tool calls
|
// Process tool results
|
||||||
if (toolResults) {
|
if (toolResults) {
|
||||||
for (const tr of toolResults) {
|
for (const tr of toolResults) {
|
||||||
const toolCallId = asString(tr.toolCallId, `tool-${Date.now()}`);
|
|
||||||
const content = tr.content;
|
const content = tr.content;
|
||||||
const isError = tr.isError === true;
|
const isError = tr.isError === true;
|
||||||
|
|
||||||
|
|
@ -151,31 +78,23 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
contentStr = content;
|
contentStr = content;
|
||||||
} else if (Array.isArray(content)) {
|
} else if (Array.isArray(content)) {
|
||||||
const extracted = extractTextContent(content as Array<{ type: string; text?: string }>);
|
contentStr = extractTextContent(content as Array<{ type: string; text?: string }>);
|
||||||
contentStr = extracted.text || JSON.stringify(content);
|
|
||||||
} else {
|
} else {
|
||||||
contentStr = JSON.stringify(content);
|
contentStr = JSON.stringify(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tool name from pending calls if available
|
|
||||||
const pendingCall = pendingToolCalls.get(toolCallId);
|
|
||||||
const toolName = asString(tr.toolName, pendingCall?.toolName || "tool");
|
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: "tool_result",
|
kind: "tool_result",
|
||||||
ts,
|
ts,
|
||||||
toolUseId: toolCallId,
|
toolUseId: asString(tr.toolCallId, "unknown"),
|
||||||
toolName,
|
toolName: asString(tr.toolName),
|
||||||
content: contentStr,
|
content: contentStr,
|
||||||
isError,
|
isError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up pending call
|
|
||||||
pendingToolCalls.delete(toolCallId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message streaming
|
// Message streaming
|
||||||
|
|
@ -187,81 +106,33 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const assistantEvent = asRecord(parsed.assistantMessageEvent);
|
const assistantEvent = asRecord(parsed.assistantMessageEvent);
|
||||||
if (assistantEvent) {
|
if (assistantEvent) {
|
||||||
const msgType = asString(assistantEvent.type);
|
const msgType = asString(assistantEvent.type);
|
||||||
|
|
||||||
// Handle thinking deltas
|
|
||||||
if (msgType === "thinking_delta") {
|
|
||||||
const delta = asString(assistantEvent.delta);
|
|
||||||
if (delta) {
|
|
||||||
return [{ kind: "thinking", ts, text: delta, delta: true }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle text deltas
|
|
||||||
if (msgType === "text_delta") {
|
if (msgType === "text_delta") {
|
||||||
const delta = asString(assistantEvent.delta);
|
const delta = asString(assistantEvent.delta);
|
||||||
if (delta) {
|
if (delta) {
|
||||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle thinking end - emit full thinking block
|
|
||||||
if (msgType === "thinking_end") {
|
|
||||||
const content = asString(assistantEvent.content);
|
|
||||||
if (content) {
|
|
||||||
return [{ kind: "thinking", ts, text: content }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle text end - emit full text block
|
|
||||||
if (msgType === "text_end") {
|
|
||||||
const content = asString(assistantEvent.content);
|
|
||||||
if (content) {
|
|
||||||
return [{ kind: "assistant", ts, text: content }];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "message_end") {
|
if (type === "message_end") {
|
||||||
const message = asRecord(parsed.message);
|
|
||||||
if (message) {
|
|
||||||
const content = message.content as string | Array<{ type: string; text?: string; thinking?: string }>;
|
|
||||||
const { text, thinking } = extractTextContent(content);
|
|
||||||
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
|
|
||||||
// Emit final thinking block if present
|
|
||||||
if (thinking) {
|
|
||||||
entries.push({ kind: "thinking", ts, text: thinking });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit final text block if present
|
|
||||||
if (text) {
|
|
||||||
entries.push({ kind: "assistant", ts, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool execution
|
// Tool execution
|
||||||
if (type === "tool_execution_start") {
|
if (type === "tool_execution_start") {
|
||||||
const toolCallId = asString(parsed.toolCallId, `tool-${Date.now()}`);
|
const toolName = asString(parsed.toolName);
|
||||||
const toolName = asString(parsed.toolName, "tool");
|
|
||||||
const args = parsed.args;
|
const args = parsed.args;
|
||||||
|
if (toolName) {
|
||||||
// Track this tool call for later matching
|
return [{
|
||||||
pendingToolCalls.set(toolCallId, { toolName, args });
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
return [{
|
name: toolName,
|
||||||
kind: "tool_call",
|
input: args,
|
||||||
ts,
|
}];
|
||||||
name: toolName,
|
}
|
||||||
input: args,
|
return [{ kind: "system", ts, text: `Tool started` }];
|
||||||
toolUseId: toolCallId,
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "tool_execution_update") {
|
if (type === "tool_execution_update") {
|
||||||
|
|
@ -269,43 +140,40 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "tool_execution_end") {
|
if (type === "tool_execution_end") {
|
||||||
const toolCallId = asString(parsed.toolCallId, `tool-${Date.now()}`);
|
const toolCallId = asString(parsed.toolCallId);
|
||||||
const toolName = asString(parsed.toolName, "tool");
|
const toolName = asString(parsed.toolName);
|
||||||
const result = parsed.result;
|
const result = parsed.result;
|
||||||
const isError = parsed.isError === true;
|
const isError = parsed.isError === true;
|
||||||
|
|
||||||
// Extract text from Pi's content array format
|
// Extract text from Pi's content array format
|
||||||
|
// Can be: {"content": [{"type": "text", "text": "..."}]} or [{"type": "text", "text": "..."}]
|
||||||
let contentStr: string;
|
let contentStr: string;
|
||||||
if (typeof result === "string") {
|
if (typeof result === "string") {
|
||||||
contentStr = result;
|
contentStr = result;
|
||||||
} else if (Array.isArray(result)) {
|
} else if (Array.isArray(result)) {
|
||||||
const extracted = extractTextContent(result as Array<{ type: string; text?: string }>);
|
// Direct array format: result is [{"type": "text", "text": "..."}]
|
||||||
contentStr = extracted.text || JSON.stringify(result);
|
contentStr = extractTextContent(result as Array<{ type: string; text?: string }>);
|
||||||
} else if (result && typeof result === "object") {
|
} else if (result && typeof result === "object") {
|
||||||
const resultObj = result as Record<string, unknown>;
|
const resultObj = result as Record<string, unknown>;
|
||||||
if (Array.isArray(resultObj.content)) {
|
if (Array.isArray(resultObj.content)) {
|
||||||
const extracted = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
|
// Wrapped format: result is {"content": [{"type": "text", "text": "..."}]}
|
||||||
contentStr = extracted.text || JSON.stringify(result);
|
contentStr = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
|
||||||
} else {
|
} else {
|
||||||
contentStr = JSON.stringify(result);
|
contentStr = JSON.stringify(result);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
contentStr = String(result);
|
contentStr = JSON.stringify(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up pending call
|
|
||||||
pendingToolCalls.delete(toolCallId);
|
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
kind: "tool_result",
|
kind: "tool_result",
|
||||||
ts,
|
ts,
|
||||||
toolUseId: toolCallId,
|
toolUseId: toolCallId || "unknown",
|
||||||
toolName,
|
toolName,
|
||||||
content: contentStr,
|
content: contentStr,
|
||||||
isError,
|
isError,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for unknown event types
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@paperclipai/branding",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./*": "./src/*.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./*": {
|
|
||||||
"types": "./dist/*.d.ts",
|
|
||||||
"import": "./dist/*.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": ["dist"],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { VOCAB, type VocabKey } from "./vocab.js";
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { VOCAB } from "./vocab.js";
|
|
||||||
|
|
||||||
describe("VOCAB", () => {
|
|
||||||
it("maps company to Workspace", () => {
|
|
||||||
expect(VOCAB.company).toBe("Workspace");
|
|
||||||
});
|
|
||||||
it("maps companies to Workspaces", () => {
|
|
||||||
expect(VOCAB.companies).toBe("Workspaces");
|
|
||||||
});
|
|
||||||
it("maps ceo to Project Manager", () => {
|
|
||||||
expect(VOCAB.ceo).toBe("Project Manager");
|
|
||||||
});
|
|
||||||
it("maps board to Owner", () => {
|
|
||||||
expect(VOCAB.board).toBe("Owner");
|
|
||||||
});
|
|
||||||
it("maps hire to Add", () => {
|
|
||||||
expect(VOCAB.hire).toBe("Add");
|
|
||||||
});
|
|
||||||
it("maps fire to Remove", () => {
|
|
||||||
expect(VOCAB.fire).toBe("Remove");
|
|
||||||
});
|
|
||||||
it("has appName as Nexus", () => {
|
|
||||||
expect(VOCAB.appName).toBe("Nexus");
|
|
||||||
});
|
|
||||||
it("has a non-empty tagline", () => {
|
|
||||||
expect(VOCAB.tagline).toBe("Open-source orchestration for your agents");
|
|
||||||
});
|
|
||||||
it("all values are non-empty strings", () => {
|
|
||||||
for (const [key, value] of Object.entries(VOCAB)) {
|
|
||||||
expect(typeof value, `key "${key}" should be a string`).toBe("string");
|
|
||||||
expect(value.length, `key "${key}" should be non-empty`).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
export const VOCAB = {
|
|
||||||
// Entity renames (display only — code identifiers unchanged)
|
|
||||||
company: "Workspace",
|
|
||||||
companies: "Workspaces",
|
|
||||||
ceo: "Project Manager",
|
|
||||||
board: "Owner",
|
|
||||||
hire: "Add",
|
|
||||||
fire: "Remove",
|
|
||||||
|
|
||||||
// Brand name
|
|
||||||
appName: "Nexus",
|
|
||||||
tagline: "Open-source orchestration for your agents",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type VocabKey = keyof typeof VOCAB;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ["src/**/*.test.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +1,83 @@
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import {
|
import {
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
|
ensurePostgresDatabase,
|
||||||
inspectMigrations,
|
inspectMigrations,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./test-embedded-postgres.js";
|
|
||||||
|
|
||||||
const cleanups: Array<() => Promise<void>> = [];
|
type EmbeddedPostgresInstance = {
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
initialise(): Promise<void>;
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
|
databaseDir: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
const tempPaths: string[] = [];
|
||||||
|
const runningInstances: EmbeddedPostgresInstance[] = [];
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createTempDatabase(): Promise<string> {
|
async function createTempDatabase(): Promise<string> {
|
||||||
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-");
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-"));
|
||||||
cleanups.push(db.cleanup);
|
tempPaths.push(dataDir);
|
||||||
return db.connectionString;
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
runningInstances.push(instance);
|
||||||
|
|
||||||
|
const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminUrl, "paperclip");
|
||||||
|
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function migrationHash(migrationFile: string): Promise<string> {
|
async function migrationHash(migrationFile: string): Promise<string> {
|
||||||
|
|
@ -30,19 +89,19 @@ async function migrationHash(migrationFile: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
while (cleanups.length > 0) {
|
while (runningInstances.length > 0) {
|
||||||
const cleanup = cleanups.pop();
|
const instance = runningInstances.pop();
|
||||||
await cleanup?.();
|
if (!instance) continue;
|
||||||
|
await instance.stop();
|
||||||
|
}
|
||||||
|
while (tempPaths.length > 0) {
|
||||||
|
const tempPath = tempPaths.pop();
|
||||||
|
if (!tempPath) continue;
|
||||||
|
fs.rmSync(tempPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
describe("applyPendingMigrations", () => {
|
||||||
console.warn(
|
|
||||||
`Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|
||||||
it(
|
it(
|
||||||
"applies an inserted earlier migration without replaying later legacy migrations",
|
"applies an inserted earlier migration without replaying later legacy migrations",
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
|
|
||||||
|
|
||||||
describe("formatEmbeddedPostgresError", () => {
|
|
||||||
it("adds a shared-memory hint when initdb logs expose the real cause", () => {
|
|
||||||
const error = formatEmbeddedPostgresError("Postgres init script exited with code 1.", {
|
|
||||||
fallbackMessage: "Failed to initialize embedded PostgreSQL cluster",
|
|
||||||
recentLogs: [
|
|
||||||
"running bootstrap script ...",
|
|
||||||
"FATAL: could not create shared memory segment: Cannot allocate memory",
|
|
||||||
"DETAIL: Failed system call was shmget(key=123, size=56, 03600).",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(error.message).toContain("could not allocate shared memory");
|
|
||||||
expect(error.message).toContain("kern.sysv.shm");
|
|
||||||
expect(error.message).toContain("could not create shared memory segment");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps only recent non-empty log lines in the collector", () => {
|
|
||||||
const buffer = createEmbeddedPostgresLogBuffer(2);
|
|
||||||
buffer.append("line one\n\n");
|
|
||||||
buffer.append("line two");
|
|
||||||
buffer.append("line three");
|
|
||||||
|
|
||||||
expect(buffer.getRecentLogs()).toEqual(["line two", "line three"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
const DEFAULT_RECENT_LOG_LIMIT = 40;
|
|
||||||
const RECENT_LOG_SUMMARY_LINES = 8;
|
|
||||||
|
|
||||||
function toError(error: unknown, fallbackMessage: string): Error {
|
|
||||||
if (error instanceof Error) return error;
|
|
||||||
if (error === undefined) return new Error(fallbackMessage);
|
|
||||||
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
|
|
||||||
} catch {
|
|
||||||
return new Error(`${fallbackMessage}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeRecentLogs(recentLogs: string[]): string | null {
|
|
||||||
if (recentLogs.length === 0) return null;
|
|
||||||
return recentLogs
|
|
||||||
.slice(-RECENT_LOG_SUMMARY_LINES)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
.join(" | ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectEmbeddedPostgresHint(recentLogs: string[]): string | null {
|
|
||||||
const haystack = recentLogs.join("\n").toLowerCase();
|
|
||||||
if (!haystack.includes("could not create shared memory segment")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
"Embedded PostgreSQL bootstrap could not allocate shared memory. " +
|
|
||||||
"On macOS, this usually means the host's kern.sysv.shm* limits are too low for another local PostgreSQL cluster. " +
|
|
||||||
"Stop other local PostgreSQL servers or raise the shared-memory sysctls, then retry."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEmbeddedPostgresLogBuffer(limit = DEFAULT_RECENT_LOG_LIMIT): {
|
|
||||||
append(message: unknown): void;
|
|
||||||
getRecentLogs(): string[];
|
|
||||||
} {
|
|
||||||
const recentLogs: string[] = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
append(message: unknown) {
|
|
||||||
const text =
|
|
||||||
typeof message === "string"
|
|
||||||
? message
|
|
||||||
: message instanceof Error
|
|
||||||
? message.message
|
|
||||||
: String(message ?? "");
|
|
||||||
|
|
||||||
for (const rawLine of text.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
recentLogs.push(line);
|
|
||||||
if (recentLogs.length > limit) {
|
|
||||||
recentLogs.splice(0, recentLogs.length - limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getRecentLogs() {
|
|
||||||
return [...recentLogs];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatEmbeddedPostgresError(
|
|
||||||
error: unknown,
|
|
||||||
input: {
|
|
||||||
fallbackMessage: string;
|
|
||||||
recentLogs?: string[];
|
|
||||||
},
|
|
||||||
): Error {
|
|
||||||
const baseError = toError(error, input.fallbackMessage);
|
|
||||||
const recentLogs = input.recentLogs ?? [];
|
|
||||||
const parts = [baseError.message];
|
|
||||||
const hint = detectEmbeddedPostgresHint(recentLogs);
|
|
||||||
const recentSummary = summarizeRecentLogs(recentLogs);
|
|
||||||
|
|
||||||
if (hint) {
|
|
||||||
parts.push(hint);
|
|
||||||
}
|
|
||||||
if (recentSummary) {
|
|
||||||
parts.push(`Recent embedded Postgres logs: ${recentSummary}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Error(parts.join(" "));
|
|
||||||
}
|
|
||||||
|
|
@ -11,12 +11,6 @@ export {
|
||||||
type MigrationBootstrapResult,
|
type MigrationBootstrapResult,
|
||||||
type Db,
|
type Db,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
export {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestSupport,
|
|
||||||
} from "./test-embedded-postgres.js";
|
|
||||||
export {
|
export {
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
runDatabaseRestore,
|
runDatabaseRestore,
|
||||||
|
|
@ -25,8 +19,4 @@ export {
|
||||||
type RunDatabaseBackupResult,
|
type RunDatabaseBackupResult,
|
||||||
type RunDatabaseRestoreOptions,
|
type RunDatabaseRestoreOptions,
|
||||||
} from "./backup-lib.js";
|
} from "./backup-lib.js";
|
||||||
export {
|
|
||||||
createEmbeddedPostgresLogBuffer,
|
|
||||||
formatEmbeddedPostgresError,
|
|
||||||
} from "./embedded-postgres-error.js";
|
|
||||||
export * from "./schema/index.js";
|
export * from "./schema/index.js";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
|
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
|
||||||
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
|
|
||||||
import { resolveDatabaseTarget } from "./runtime-config.js";
|
import { resolveDatabaseTarget } from "./runtime-config.js";
|
||||||
|
|
||||||
type EmbeddedPostgresInstance = {
|
type EmbeddedPostgresInstance = {
|
||||||
|
|
@ -28,6 +27,18 @@ export type MigrationConnection = {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toError(error: unknown, fallbackMessage: string): Error {
|
||||||
|
if (error instanceof Error) return error;
|
||||||
|
if (error === undefined) return new Error(fallbackMessage);
|
||||||
|
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
|
||||||
|
} catch {
|
||||||
|
return new Error(`${fallbackMessage}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||||
if (!existsSync(postmasterPidFile)) return null;
|
if (!existsSync(postmasterPidFile)) return null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -98,7 +109,6 @@ async function ensureEmbeddedPostgresConnection(
|
||||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||||
const runningPort = readPidFilePort(postmasterPidFile);
|
const runningPort = readPidFilePort(postmasterPidFile);
|
||||||
const preferredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
|
const preferredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
|
||||||
const logBuffer = createEmbeddedPostgresLogBuffer();
|
|
||||||
|
|
||||||
if (!runningPid && existsSync(pgVersionFile)) {
|
if (!runningPid && existsSync(pgVersionFile)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -140,20 +150,19 @@ async function ensureEmbeddedPostgresConnection(
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port: selectedPort,
|
port: selectedPort,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
onLog: logBuffer.append,
|
onLog: () => {},
|
||||||
onError: logBuffer.append,
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||||
try {
|
try {
|
||||||
await instance.initialise();
|
await instance.initialise();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw formatEmbeddedPostgresError(error, {
|
throw toError(
|
||||||
fallbackMessage:
|
error,
|
||||||
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
|
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existsSync(postmasterPidFile)) {
|
if (existsSync(postmasterPidFile)) {
|
||||||
|
|
@ -162,10 +171,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||||
try {
|
try {
|
||||||
await instance.start();
|
await instance.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw formatEmbeddedPostgresError(error, {
|
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
|
||||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${selectedPort}`,
|
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
CREATE TABLE "issue_inbox_archives" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"company_id" uuid NOT NULL,
|
|
||||||
"issue_id" uuid NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"archived_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
DROP INDEX "board_api_keys_key_hash_idx";--> statement-breakpoint
|
|
||||||
ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_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 "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "issue_inbox_archives_company_issue_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "issue_inbox_archives_company_user_idx" ON "issue_inbox_archives" USING btree ("company_id","user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "issue_inbox_archives_company_issue_user_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id","user_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash");
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -316,13 +316,6 @@
|
||||||
"when": 1774269579794,
|
"when": 1774269579794,
|
||||||
"tag": "0044_illegal_toad",
|
"tag": "0044_illegal_toad",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 45,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1774530504348,
|
|
||||||
"tag": "0045_workable_shockwave",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +31,6 @@ export { labels } from "./labels.js";
|
||||||
export { issueLabels } from "./issue_labels.js";
|
export { issueLabels } from "./issue_labels.js";
|
||||||
export { issueApprovals } from "./issue_approvals.js";
|
export { issueApprovals } from "./issue_approvals.js";
|
||||||
export { issueComments } from "./issue_comments.js";
|
export { issueComments } from "./issue_comments.js";
|
||||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
|
||||||
export { issueReadStates } from "./issue_read_states.js";
|
export { issueReadStates } from "./issue_read_states.js";
|
||||||
export { assets } from "./assets.js";
|
export { assets } from "./assets.js";
|
||||||
export { issueAttachments } from "./issue_attachments.js";
|
export { issueAttachments } from "./issue_attachments.js";
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
|
||||||
import { companies } from "./companies.js";
|
|
||||||
import { issues } from "./issues.js";
|
|
||||||
|
|
||||||
export const issueInboxArchives = pgTable(
|
|
||||||
"issue_inbox_archives",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
|
||||||
issueId: uuid("issue_id").notNull().references(() => issues.id),
|
|
||||||
userId: text("user_id").notNull(),
|
|
||||||
archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
companyIssueIdx: index("issue_inbox_archives_company_issue_idx").on(table.companyId, table.issueId),
|
|
||||||
companyUserIdx: index("issue_inbox_archives_company_user_idx").on(table.companyId, table.userId),
|
|
||||||
companyIssueUserUnique: uniqueIndex("issue_inbox_archives_company_issue_user_idx").on(
|
|
||||||
table.companyId,
|
|
||||||
table.issueId,
|
|
||||||
table.userId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import net from "node:net";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { applyPendingMigrations, ensurePostgresDatabase } from "./client.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;
|
|
||||||
initdbFlags?: string[];
|
|
||||||
onLog?: (message: unknown) => void;
|
|
||||||
onError?: (message: unknown) => void;
|
|
||||||
}) => EmbeddedPostgresInstance;
|
|
||||||
|
|
||||||
export type EmbeddedPostgresTestSupport = {
|
|
||||||
supported: boolean;
|
|
||||||
reason?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EmbeddedPostgresTestDatabase = {
|
|
||||||
connectionString: string;
|
|
||||||
cleanup(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let embeddedPostgresSupportPromise: Promise<EmbeddedPostgresTestSupport> | null = null;
|
|
||||||
|
|
||||||
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
|
||||||
const mod = await import("embedded-postgres");
|
|
||||||
return mod.default as EmbeddedPostgresCtor;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.on("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { port } = address;
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) reject(error);
|
|
||||||
else resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEmbeddedPostgresError(error: unknown): string {
|
|
||||||
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
||||||
if (typeof error === "string" && error.length > 0) return error;
|
|
||||||
return "embedded Postgres startup failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> {
|
|
||||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-"));
|
|
||||||
const port = await getAvailablePort();
|
|
||||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
|
||||||
const instance = new EmbeddedPostgres({
|
|
||||||
databaseDir: dataDir,
|
|
||||||
user: "paperclip",
|
|
||||||
password: "paperclip",
|
|
||||||
port,
|
|
||||||
persistent: true,
|
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
|
||||||
onLog: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await instance.initialise();
|
|
||||||
await instance.start();
|
|
||||||
return { supported: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
supported: false,
|
|
||||||
reason: formatEmbeddedPostgresError(error),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await instance.stop().catch(() => {});
|
|
||||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgresTestSupport> {
|
|
||||||
if (!embeddedPostgresSupportPromise) {
|
|
||||||
embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport();
|
|
||||||
}
|
|
||||||
return await embeddedPostgresSupportPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startEmbeddedPostgresTestDatabase(
|
|
||||||
tempDirPrefix: string,
|
|
||||||
): Promise<EmbeddedPostgresTestDatabase> {
|
|
||||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix));
|
|
||||||
const port = await getAvailablePort();
|
|
||||||
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
|
||||||
const instance = new EmbeddedPostgres({
|
|
||||||
databaseDir: dataDir,
|
|
||||||
user: "paperclip",
|
|
||||||
password: "paperclip",
|
|
||||||
port,
|
|
||||||
persistent: true,
|
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
|
||||||
onLog: () => {},
|
|
||||||
onError: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await instance.initialise();
|
|
||||||
await instance.start();
|
|
||||||
|
|
||||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
|
||||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
|
||||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
|
||||||
await applyPendingMigrations(connectionString);
|
|
||||||
|
|
||||||
return {
|
|
||||||
connectionString,
|
|
||||||
cleanup: async () => {
|
|
||||||
await instance.stop().catch(() => {});
|
|
||||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await instance.stop().catch(() => {});
|
|
||||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
throw new Error(
|
|
||||||
`Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const AGENT_ROLES = [
|
||||||
export type AgentRole = (typeof AGENT_ROLES)[number];
|
export type AgentRole = (typeof AGENT_ROLES)[number];
|
||||||
|
|
||||||
export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
|
||||||
ceo: "Project Manager", // [nexus] was: "CEO"
|
ceo: "CEO",
|
||||||
cto: "CTO",
|
cto: "CTO",
|
||||||
cmo: "CMO",
|
cmo: "CMO",
|
||||||
cfo: "CFO",
|
cfo: "CFO",
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
diff --git a/dist/index.js b/dist/index.js
|
|
||||||
--- a/dist/index.js
|
|
||||||
+++ b/dist/index.js
|
|
||||||
@@ -23,7 +23,7 @@
|
|
||||||
* for a particular string, we need to force that string into the right locale.
|
|
||||||
* @see https://github.com/leinelissen/embedded-postgres/issues/15
|
|
||||||
*/
|
|
||||||
-const LC_MESSAGES_LOCALE = 'en_US.UTF-8';
|
|
||||||
+const LC_MESSAGES_LOCALE = 'C';
|
|
||||||
// The default configuration options for the class
|
|
||||||
const defaults = {
|
|
||||||
databaseDir: path.join(process.cwd(), 'data', 'db'),
|
|
||||||
@@ -133,7 +133,7 @@
|
|
||||||
`--pwfile=${passwordFile}`,
|
|
||||||
`--lc-messages=${LC_MESSAGES_LOCALE}`,
|
|
||||||
...this.options.initdbFlags,
|
|
||||||
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
|
||||||
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
|
||||||
// Connect to stderr, as that is where the messages get sent
|
|
||||||
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
|
||||||
// Parse the data as a string and log it
|
|
||||||
@@ -177,7 +177,7 @@
|
|
||||||
'-p',
|
|
||||||
this.options.port.toString(),
|
|
||||||
...this.options.postgresFlags,
|
|
||||||
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
|
||||||
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, globalThis.process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
|
||||||
// Connect to stderr, as that is where the messages get sent
|
|
||||||
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
|
||||||
// Parse the data as a string and log it
|
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
|
|
@ -4,11 +4,6 @@ settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
patchedDependencies:
|
|
||||||
embedded-postgres@18.1.0-beta.16:
|
|
||||||
hash: 55uhvnotpqyiy37rn3pqpukhei
|
|
||||||
path: patches/embedded-postgres@18.1.0-beta.16.patch
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
|
@ -58,9 +53,6 @@ importers:
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapter-utils
|
version: link:../packages/adapter-utils
|
||||||
'@paperclipai/branding':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../packages/branding
|
|
||||||
'@paperclipai/db':
|
'@paperclipai/db':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/db
|
version: link:../packages/db
|
||||||
|
|
@ -81,7 +73,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16
|
||||||
picocolors:
|
picocolors:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
|
|
@ -223,12 +215,6 @@ importers:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/branding:
|
|
||||||
devDependencies:
|
|
||||||
typescript:
|
|
||||||
specifier: ^5.7.3
|
|
||||||
version: 5.9.3
|
|
||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@paperclipai/shared':
|
'@paperclipai/shared':
|
||||||
|
|
@ -239,7 +225,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16
|
||||||
postgres:
|
postgres:
|
||||||
specifier: ^3.4.5
|
specifier: ^3.4.5
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
|
|
@ -508,7 +494,7 @@ importers:
|
||||||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||||
embedded-postgres:
|
embedded-postgres:
|
||||||
specifier: ^18.1.0-beta.16
|
specifier: ^18.1.0-beta.16
|
||||||
version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei)
|
version: 18.1.0-beta.16
|
||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
|
@ -627,9 +613,6 @@ importers:
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapter-utils
|
version: link:../packages/adapter-utils
|
||||||
'@paperclipai/branding':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../packages/branding
|
|
||||||
'@paperclipai/shared':
|
'@paperclipai/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/shared
|
version: link:../packages/shared
|
||||||
|
|
@ -9990,7 +9973,7 @@ snapshots:
|
||||||
|
|
||||||
electron-to-chromium@1.5.286: {}
|
electron-to-chromium@1.5.286: {}
|
||||||
|
|
||||||
embedded-postgres@18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei):
|
embedded-postgres@18.1.0-beta.16:
|
||||||
dependencies:
|
dependencies:
|
||||||
async-exit-hook: 2.0.1
|
async-exit-hook: 2.0.1
|
||||||
pg: 8.18.0
|
pg: 8.18.0
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# v2026.325.0
|
|
||||||
|
|
||||||
> Released: 2026-03-25
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **Company import/export** — Full company portability with a file-browser UX for importing and exporting agent companies. Includes rich frontmatter preview, nested file picker, merge-history support, GitHub shorthand refs, and CLI `company import`/`company export` commands. Imported companies open automatically after import, and heartbeat timers are disabled for imported agents by default. ([#840](https://github.com/paperclipai/paperclip/pull/840), [#1631](https://github.com/paperclipai/paperclip/pull/1631), [#1632](https://github.com/paperclipai/paperclip/pull/1632), [#1655](https://github.com/paperclipai/paperclip/pull/1655))
|
|
||||||
- **Company skills library** — New company-scoped skills system with a skills UI, agent skill sync across all local adapters (Claude, Codex, Pi, Gemini), pinned GitHub skills with update checks, and built-in skill support. ([#1346](https://github.com/paperclipai/paperclip/pull/1346))
|
|
||||||
- **Routines and recurring tasks** — Full routines engine with triggers, routine runs, coalescing, and recurring task portability. Includes API documentation and routine export support. ([#1351](https://github.com/paperclipai/paperclip/pull/1351), [#1622](https://github.com/paperclipai/paperclip/pull/1622), @aronprins)
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- **Inline join requests in inbox** — Join requests now render inline in the inbox alongside approvals and other work items.
|
|
||||||
- **Onboarding seeding** — New projects and issues are seeded with goal context during onboarding for a better first-run experience.
|
|
||||||
- **Agent instructions recovery** — Managed agent instructions are recovered from disk on startup; instructions are preserved across adapter switches.
|
|
||||||
- **Heartbeats settings page** — Shows all agents regardless of interval config; added a "Disable All" button for quick bulk control.
|
|
||||||
- **Agent history via participation** — Agent issue history now uses participation records instead of direct assignment lookups.
|
|
||||||
- **Alphabetical agent sorting** — Agents are sorted alphabetically by name across all views.
|
|
||||||
- **Company org chart assets** — Improved generated org chart visuals for companies.
|
|
||||||
- **Improved CLI API connection errors** — Better error messages when the CLI cannot reach the Paperclip API.
|
|
||||||
- **Markdown mention links** — Custom URL schemes are now allowed in Lexical LinkNode, enabling mention pills with proper linking behavior. Atomic deletion of mention pills works correctly.
|
|
||||||
- **Issue workspace reuse** — Workspaces are correctly reused after isolation runs.
|
|
||||||
- **Failed-run session resume** — Explicit failed-run sessions can now be resumed via honor flag.
|
|
||||||
- **Docker image CI** — Added Docker image build and deploy workflow. ([#542](https://github.com/paperclipai/paperclip/pull/542), @albttx)
|
|
||||||
- **Project filter on issues** — Issues list can now be filtered by project. ([#552](https://github.com/paperclipai/paperclip/pull/552), @mvanhorn)
|
|
||||||
- **Inline comment image attachments** — Uploaded images are now embedded inline in comments. ([#551](https://github.com/paperclipai/paperclip/pull/551), @mvanhorn)
|
|
||||||
- **AGENTS.md fallback** — Claude-local adapter gracefully falls back when AGENTS.md is missing. ([#550](https://github.com/paperclipai/paperclip/pull/550), @mvanhorn)
|
|
||||||
- **Company-creator skill** — New skill for scaffolding agent company packages from scratch.
|
|
||||||
- **Reports page rename** — Reports section renamed for clarity. ([#1380](https://github.com/paperclipai/paperclip/pull/1380), @DanielSousa)
|
|
||||||
- **Eval framework bootstrap** — Promptfoo-based evaluation framework with YAML test cases for systematic agent behavior testing. ([#832](https://github.com/paperclipai/paperclip/pull/832), @mvanhorn)
|
|
||||||
- **Board CLI authentication** — Browser-based auth flow for the CLI so board users can authenticate without manually copying API keys. ([#1635](https://github.com/paperclipai/paperclip/pull/1635))
|
|
||||||
|
|
||||||
## Fixes
|
|
||||||
|
|
||||||
- **Embedded Postgres initdb in Docker slim** — Fixed initdb failure in slim containers by adding proper initdbFlags types. ([#737](https://github.com/paperclipai/paperclip/pull/737), @alaa-alghazouli)
|
|
||||||
- **OpenClaw gateway crash** — Fixed unhandled rejection when challengePromise fails. ([#743](https://github.com/paperclipai/paperclip/pull/743), @Sigmabrogz)
|
|
||||||
- **Agent mention pill alignment** — Fixed vertical misalignment between agent mention pills and project mention pills.
|
|
||||||
- **Task assignment grants** — Preserved task assignment grants for agents that have already joined.
|
|
||||||
- **Instructions tab state** — Fixed tab state not updating correctly when switching between agents.
|
|
||||||
- **Imported agent bundle frontmatter** — Fixed frontmatter leakage in imported agent bundles.
|
|
||||||
- **Login form 1Password detection** — Fixed login form not being detected by password managers; Enter key now submits correctly. ([#1014](https://github.com/paperclipai/paperclip/pull/1014))
|
|
||||||
- **Pill contrast (WCAG)** — Improved mention pill contrast using WCAG contrast ratios on composited backgrounds.
|
|
||||||
- **Documents horizontal scroll** — Prevented documents row from causing horizontal scroll on mobile.
|
|
||||||
- **Toggle switch sizing** — Fixed oversized toggle switches on mobile; added missing `data-slot` attributes.
|
|
||||||
- **Agent instructions tab responsive** — Made agent instructions tab responsive on mobile.
|
|
||||||
- **Monospace font sizing** — Adjusted inline code font size and added dark mode background.
|
|
||||||
- **Priority icon removal** — Removed priority icon from issue rows for a cleaner list view.
|
|
||||||
- **Same-page issue toasts** — Suppressed redundant toasts when navigating to an issue already on screen.
|
|
||||||
- **Noisy adapter log** — Removed noisy "Loaded agent instructions file" log message from all adapters.
|
|
||||||
- **Pi local adapter** — Fixed Pi adapter missing from `isLocal` check. ([#1382](https://github.com/paperclipai/paperclip/pull/1382), @lucas-stellet)
|
|
||||||
- **CLI auth migration idempotency** — Made migration 0044 idempotent to avoid failures on re-run.
|
|
||||||
- **Dev restart tracking** — `.paperclip` and test-only paths are now ignored in dev restart detection.
|
|
||||||
- **Duplicate CLI auth flag** — Fixed duplicate `--company` flag on `auth login`.
|
|
||||||
- **Gemini local execution** — Fixed Gemini local adapter execution and diagnostics.
|
|
||||||
- **Sidebar ordering** — Preserved sidebar ordering during company portability operations.
|
|
||||||
- **Company skill deduplication** — Fixed duplicate skill inventory refreshes.
|
|
||||||
- **Worktree merge-history migrations** — Fixed migration handling in worktree contexts. ([#1385](https://github.com/paperclipai/paperclip/pull/1385))
|
|
||||||
|
|
||||||
## Upgrade Guide
|
|
||||||
|
|
||||||
Seven new database migrations (`0038`–`0044`) will run automatically on startup:
|
|
||||||
|
|
||||||
- **Migration 0038** adds process tracking columns to heartbeat runs (PID, started-at, retry tracking).
|
|
||||||
- **Migration 0039** adds the routines engine tables (routines, triggers, routine runs).
|
|
||||||
- **Migrations 0040–0042** extend company skills, recurring tasks, and portability metadata.
|
|
||||||
- **Migration 0043** adds the Codex managed-home and agent instructions recovery columns.
|
|
||||||
- **Migration 0044** adds board API keys and CLI auth challenge tables for browser-based CLI auth.
|
|
||||||
|
|
||||||
All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
|
|
||||||
|
|
||||||
If you use the company import/export feature, note that imported companies have heartbeat timers disabled by default. Re-enable them manually from the Heartbeats settings page after verifying adapter configuration.
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
Thank you to everyone who contributed to this release!
|
|
||||||
|
|
||||||
@alaa-alghazouli, @albttx, @AOrobator, @aronprins, @cryppadotta, @DanielSousa, @lucas-stellet, @mvanhorn, @richardanaya, @Sigmabrogz
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const repoRoot = resolve(__dirname, "..");
|
|
||||||
const uiDir = join(repoRoot, "ui");
|
|
||||||
const packageJsonPath = join(uiDir, "package.json");
|
|
||||||
|
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
||||||
|
|
||||||
const publishPackageJson = {
|
|
||||||
name: packageJson.name,
|
|
||||||
version: packageJson.version,
|
|
||||||
description: packageJson.description,
|
|
||||||
license: packageJson.license,
|
|
||||||
homepage: packageJson.homepage,
|
|
||||||
bugs: packageJson.bugs,
|
|
||||||
repository: packageJson.repository,
|
|
||||||
type: packageJson.type,
|
|
||||||
files: ["dist"],
|
|
||||||
publishConfig: {
|
|
||||||
access: "public",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
|
||||||
|
|
||||||
console.log(" ✓ Generated publishable UI package.json");
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Install Nexus git hooks
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
||||||
cp "$REPO_ROOT/scripts/nexus-commit-msg-hook.sh" "$REPO_ROOT/.git/hooks/commit-msg"
|
|
||||||
chmod +x "$REPO_ROOT/.git/hooks/commit-msg"
|
|
||||||
echo "Nexus commit-msg hook installed."
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Nexus fork: enforce [nexus] prefix on all fork commits
|
|
||||||
# Allows upstream merge commits and rebase-generated commits through
|
|
||||||
MSG_FILE="$1"
|
|
||||||
FIRST_LINE=$(head -1 "$MSG_FILE")
|
|
||||||
|
|
||||||
# Skip merge commits (git generates these automatically during rebase/merge)
|
|
||||||
if echo "$FIRST_LINE" | grep -qE "^Merge (branch|pull request|remote-tracking)"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip fixup/squash commits (used during interactive rebase)
|
|
||||||
if echo "$FIRST_LINE" | grep -qE "^(fixup|squash)!"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enforce [nexus] prefix
|
|
||||||
if ! echo "$FIRST_LINE" | grep -qE "^\[nexus\]"; then
|
|
||||||
echo "ERROR: Commit message must start with [nexus]"
|
|
||||||
echo " Got: $FIRST_LINE"
|
|
||||||
echo " Example: [nexus] feat: add branding package"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
@ -3,12 +3,6 @@ set -euo pipefail
|
||||||
|
|
||||||
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}"
|
||||||
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
||||||
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
|
|
||||||
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
|
|
||||||
paperclip_dir="$worktree_cwd/.paperclip"
|
|
||||||
worktree_config_path="$paperclip_dir/config.json"
|
|
||||||
worktree_env_path="$paperclip_dir/.env"
|
|
||||||
worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}"
|
|
||||||
|
|
||||||
if [[ ! -d "$base_cwd" ]]; then
|
if [[ ! -d "$base_cwd" ]]; then
|
||||||
echo "Base workspace does not exist: $base_cwd" >&2
|
echo "Base workspace does not exist: $base_cwd" >&2
|
||||||
|
|
@ -20,286 +14,6 @@ if [[ ! -d "$worktree_cwd" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
source_config_path="${PAPERCLIP_CONFIG:-}"
|
|
||||||
if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then
|
|
||||||
source_config_path="$base_cwd/.paperclip/config.json"
|
|
||||||
fi
|
|
||||||
if [[ -z "$source_config_path" ]]; then
|
|
||||||
source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
|
|
||||||
fi
|
|
||||||
source_env_path="$(dirname "$source_config_path")/.env"
|
|
||||||
|
|
||||||
mkdir -p "$paperclip_dir"
|
|
||||||
|
|
||||||
run_isolated_worktree_init() {
|
|
||||||
if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then
|
|
||||||
pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v paperclipai >/dev/null 2>&1; then
|
|
||||||
paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
write_fallback_worktree_config() {
|
|
||||||
WORKTREE_NAME="$worktree_name" \
|
|
||||||
BASE_CWD="$base_cwd" \
|
|
||||||
WORKTREE_CWD="$worktree_cwd" \
|
|
||||||
PAPERCLIP_DIR="$paperclip_dir" \
|
|
||||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
|
||||||
SOURCE_ENV_PATH="$source_env_path" \
|
|
||||||
PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \
|
|
||||||
node <<'EOF'
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const net = require("node:net");
|
|
||||||
|
|
||||||
function expandHomePrefix(value) {
|
|
||||||
if (!value) return value;
|
|
||||||
if (value === "~") return os.homedir();
|
|
||||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nonEmpty(value) {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeInstanceId(value) {
|
|
||||||
const trimmed = String(value ?? "").trim().toLowerCase();
|
|
||||||
const normalized = trimmed
|
|
||||||
.replace(/[^a-z0-9_-]+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^[-_]+|[-_]+$/g, "");
|
|
||||||
return normalized || "worktree";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvFile(contents) {
|
|
||||||
const entries = {};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findAvailablePort(preferredPort, reserved = new Set()) {
|
|
||||||
const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0;
|
|
||||||
if (startPort > 0) {
|
|
||||||
for (let port = startPort; port < startPort + 100; port += 1) {
|
|
||||||
if (reserved.has(port)) continue;
|
|
||||||
const available = await new Promise((resolve) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.once("error", () => resolve(false));
|
|
||||||
server.listen(port, "127.0.0.1", () => {
|
|
||||||
server.close(() => resolve(true));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (available) return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to allocate a port.")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = address.port;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackHost(hostname) {
|
|
||||||
const value = hostname.trim().toLowerCase();
|
|
||||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewriteLocalUrlPort(rawUrl, port) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRuntimeLikePath(value, configPath) {
|
|
||||||
const expanded = expandHomePrefix(value);
|
|
||||||
if (path.isAbsolute(expanded)) return expanded;
|
|
||||||
return path.resolve(path.dirname(configPath), expanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const worktreeName = process.env.WORKTREE_NAME;
|
|
||||||
const paperclipDir = process.env.PAPERCLIP_DIR;
|
|
||||||
const sourceConfigPath = process.env.SOURCE_CONFIG_PATH;
|
|
||||||
const sourceEnvPath = process.env.SOURCE_ENV_PATH;
|
|
||||||
const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees"));
|
|
||||||
const instanceId = sanitizeInstanceId(worktreeName);
|
|
||||||
const instanceRoot = path.resolve(worktreeHome, "instances", instanceId);
|
|
||||||
const configPath = path.resolve(paperclipDir, "config.json");
|
|
||||||
const envPath = path.resolve(paperclipDir, ".env");
|
|
||||||
|
|
||||||
let sourceConfig = null;
|
|
||||||
if (sourceConfigPath && fs.existsSync(sourceConfigPath)) {
|
|
||||||
sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceEnvEntries =
|
|
||||||
sourceEnvPath && fs.existsSync(sourceEnvPath)
|
|
||||||
? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8"))
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1;
|
|
||||||
const serverPort = await findAvailablePort(preferredServerPort);
|
|
||||||
const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1;
|
|
||||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
|
||||||
|
|
||||||
fs.rmSync(configPath, { force: true });
|
|
||||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
||||||
fs.mkdirSync(instanceRoot, { recursive: true });
|
|
||||||
|
|
||||||
const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort);
|
|
||||||
const targetConfig = {
|
|
||||||
$meta: {
|
|
||||||
version: 1,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
source: "configure",
|
|
||||||
},
|
|
||||||
...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: databasePort,
|
|
||||||
backup: {
|
|
||||||
enabled: sourceConfig?.database?.backup?.enabled ?? true,
|
|
||||||
intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60,
|
|
||||||
retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30,
|
|
||||||
dir: path.resolve(instanceRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: sourceConfig?.logging?.mode ?? "file",
|
|
||||||
logDir: path.resolve(instanceRoot, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted",
|
|
||||||
exposure: sourceConfig?.server?.exposure ?? "private",
|
|
||||||
host: sourceConfig?.server?.host ?? "127.0.0.1",
|
|
||||||
port: serverPort,
|
|
||||||
allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [],
|
|
||||||
serveUi: sourceConfig?.server?.serveUi ?? true,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto",
|
|
||||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
|
||||||
disableSignUp: sourceConfig?.auth?.disableSignUp ?? false,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: sourceConfig?.storage?.provider ?? "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.resolve(instanceRoot, "data", "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip",
|
|
||||||
region: sourceConfig?.storage?.s3?.region ?? "us-east-1",
|
|
||||||
endpoint: sourceConfig?.storage?.s3?.endpoint,
|
|
||||||
prefix: sourceConfig?.storage?.s3?.prefix ?? "",
|
|
||||||
forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: sourceConfig?.secrets?.provider ?? "local_encrypted",
|
|
||||||
strictMode: sourceConfig?.secrets?.strictMode ?? false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 });
|
|
||||||
|
|
||||||
const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY);
|
|
||||||
if (inlineMasterKey) {
|
|
||||||
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
|
|
||||||
fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, {
|
|
||||||
encoding: "utf8",
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE)
|
|
||||||
? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath)
|
|
||||||
: nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath)
|
|
||||||
? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) {
|
|
||||||
fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true });
|
|
||||||
fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath);
|
|
||||||
fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envLines = [
|
|
||||||
"PAPERCLIP_HOME=" + JSON.stringify(worktreeHome),
|
|
||||||
"PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId),
|
|
||||||
"PAPERCLIP_CONFIG=" + JSON.stringify(configPath),
|
|
||||||
"PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")),
|
|
||||||
"PAPERCLIP_IN_WORKTREE=true",
|
|
||||||
"PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName),
|
|
||||||
];
|
|
||||||
|
|
||||||
const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET);
|
|
||||||
if (agentJwtSecret) {
|
|
||||||
envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret));
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! run_isolated_worktree_init; then
|
|
||||||
echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2
|
|
||||||
write_fallback_worktree_config
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r relative_path; do
|
while IFS= read -r relative_path; do
|
||||||
[[ -n "$relative_path" ]] || continue
|
[[ -n "$relative_path" ]] || continue
|
||||||
source_path="$base_cwd/$relative_path"
|
source_path="$base_cwd/$relative_path"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
|
||||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||||
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
||||||
"prepack": "pnpm run prepare:ui-dist",
|
"prepack": "pnpm run prepare:ui-dist",
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const tsxCliPath = require.resolve("tsx/dist/cli.mjs");
|
|
||||||
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);
|
|
||||||
|
|
||||||
const child = spawn(
|
|
||||||
process.execPath,
|
|
||||||
[tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"],
|
|
||||||
{
|
|
||||||
cwd: serverRoot,
|
|
||||||
env: process.env,
|
|
||||||
stdio: "inherit",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
|
||||||
if (signal) {
|
|
||||||
process.kill(process.pid, signal);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.exit(code ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -7,12 +7,6 @@ import { testEnvironment } from "@paperclipai/adapter-codex-local/server";
|
||||||
const itWindows = process.platform === "win32" ? it : it.skip;
|
const itWindows = process.platform === "win32" ? it : it.skip;
|
||||||
|
|
||||||
describe("codex_local environment diagnostics", () => {
|
describe("codex_local environment diagnostics", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubEnv("OPENAI_API_KEY", "");
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
it("creates a missing working directory when cwd is absolute", async () => {
|
it("creates a missing working directory when cwd is absolute", async () => {
|
||||||
const cwd = path.join(
|
const cwd = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
|
|
@ -38,67 +32,6 @@ describe("codex_local environment diagnostics", () => {
|
||||||
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits codex_native_auth_present when ~/.codex/auth.json exists and OPENAI_API_KEY is unset", async () => {
|
|
||||||
const root = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`paperclip-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
);
|
|
||||||
const codexHome = path.join(root, ".codex");
|
|
||||||
const cwd = path.join(root, "workspace");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(codexHome, { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(codexHome, "auth.json"),
|
|
||||||
JSON.stringify({ accessToken: "fake-token", accountId: "acct-1" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await testEnvironment({
|
|
||||||
companyId: "company-1",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
config: {
|
|
||||||
command: process.execPath,
|
|
||||||
cwd,
|
|
||||||
env: { CODEX_HOME: codexHome },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(true);
|
|
||||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits codex_openai_api_key_missing when neither env var nor native auth exists", async () => {
|
|
||||||
const root = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`paperclip-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
);
|
|
||||||
const codexHome = path.join(root, ".codex");
|
|
||||||
const cwd = path.join(root, "workspace");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(codexHome, { recursive: true });
|
|
||||||
// No auth.json written
|
|
||||||
|
|
||||||
const result = await testEnvironment({
|
|
||||||
companyId: "company-1",
|
|
||||||
adapterType: "codex_local",
|
|
||||||
config: {
|
|
||||||
command: process.execPath,
|
|
||||||
cwd,
|
|
||||||
env: { CODEX_HOME: codexHome },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.checks.some((check) => check.code === "codex_openai_api_key_missing")).toBe(true);
|
|
||||||
expect(result.checks.some((check) => check.code === "codex_native_auth_present")).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
|
itWindows("runs the hello probe when Codex is available via a Windows .cmd wrapper", async () => {
|
||||||
const root = path.join(
|
const root = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ describe("codex execute", () => {
|
||||||
"company-1",
|
"company-1",
|
||||||
"codex-home",
|
"codex-home",
|
||||||
);
|
);
|
||||||
const homeSkill = path.join(isolatedCodexHome, "skills", "paperclip");
|
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip");
|
||||||
await fs.mkdir(workspace, { recursive: true });
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||||
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||||
|
|
@ -284,7 +284,7 @@ describe("codex execute", () => {
|
||||||
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
||||||
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
||||||
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
||||||
expect((await fs.lstat(homeSkill)).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true);
|
||||||
expect(logs).toContainEqual(
|
expect(logs).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
stream: "stdout",
|
stream: "stdout",
|
||||||
|
|
@ -371,7 +371,7 @@ describe("codex execute", () => {
|
||||||
|
|
||||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
expect(capture.codexHome).toBe(explicitCodexHome);
|
expect(capture.codexHome).toBe(explicitCodexHome);
|
||||||
expect((await fs.lstat(path.join(explicitCodexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome === undefined) delete process.env.HOME;
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ describe("codex local skill sync", () => {
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
|
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -28,13 +28,6 @@ console.log(JSON.stringify({
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("cursor environment diagnostics", () => {
|
describe("cursor environment diagnostics", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubEnv("CURSOR_API_KEY", "");
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a missing working directory when cwd is absolute", async () => {
|
it("creates a missing working directory when cwd is absolute", async () => {
|
||||||
const cwd = path.join(
|
const cwd = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
|
|
@ -123,73 +116,4 @@ describe("cursor environment diagnostics", () => {
|
||||||
expect(args).not.toContain("--trust");
|
expect(args).not.toContain("--trust");
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits cursor_native_auth_present when cli-config.json has authInfo and CURSOR_API_KEY is unset", async () => {
|
|
||||||
const root = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`paperclip-cursor-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
);
|
|
||||||
const cursorHome = path.join(root, ".cursor");
|
|
||||||
const cwd = path.join(root, "workspace");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(cursorHome, { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(cursorHome, "cli-config.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
authInfo: {
|
|
||||||
email: "test@example.com",
|
|
||||||
displayName: "Test User",
|
|
||||||
userId: 12345,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await testEnvironment({
|
|
||||||
companyId: "company-1",
|
|
||||||
adapterType: "cursor",
|
|
||||||
config: {
|
|
||||||
command: process.execPath,
|
|
||||||
cwd,
|
|
||||||
env: { CURSOR_HOME: cursorHome },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(true);
|
|
||||||
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(false);
|
|
||||||
const authCheck = result.checks.find((check) => check.code === "cursor_native_auth_present");
|
|
||||||
expect(authCheck?.detail).toContain("test@example.com");
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits cursor_api_key_missing when neither env var nor native auth exists", async () => {
|
|
||||||
const root = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`paperclip-cursor-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
);
|
|
||||||
const cursorHome = path.join(root, ".cursor");
|
|
||||||
const cwd = path.join(root, "workspace");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(cursorHome, { recursive: true });
|
|
||||||
// No cli-config.json written
|
|
||||||
|
|
||||||
const result = await testEnvironment({
|
|
||||||
companyId: "company-1",
|
|
||||||
adapterType: "cursor",
|
|
||||||
config: {
|
|
||||||
command: process.execPath,
|
|
||||||
cwd,
|
|
||||||
env: { CURSOR_HOME: cursorHome },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.checks.some((check) => check.code === "cursor_api_key_missing")).toBe(true);
|
|
||||||
expect(result.checks.some((check) => check.code === "cursor_native_auth_present")).toBe(false);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js";
|
|
||||||
|
|
||||||
describe("resolveServerDevWatchIgnorePaths", () => {
|
|
||||||
it("includes both the worktree UI paths and their real shared targets", () => {
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-"));
|
|
||||||
const sharedUiRoot = path.join(tempRoot, "shared-ui");
|
|
||||||
const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884");
|
|
||||||
const serverRoot = path.join(worktreeRoot, "server");
|
|
||||||
const worktreeUiRoot = path.join(worktreeRoot, "ui");
|
|
||||||
|
|
||||||
fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true });
|
|
||||||
fs.mkdirSync(serverRoot, { recursive: true });
|
|
||||||
fs.mkdirSync(worktreeUiRoot, { recursive: true });
|
|
||||||
|
|
||||||
fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules"));
|
|
||||||
fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite"));
|
|
||||||
fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist"));
|
|
||||||
|
|
||||||
const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot);
|
|
||||||
|
|
||||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules"));
|
|
||||||
expect(ignorePaths).toContain(`${path.join(worktreeUiRoot, "node_modules").replaceAll(path.sep, "/")}/**`);
|
|
||||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules")));
|
|
||||||
expect(ignorePaths).toContain(`${fs.realpathSync(path.join(sharedUiRoot, "node_modules")).replaceAll(path.sep, "/")}/**`);
|
|
||||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules", ".vite-temp"));
|
|
||||||
expect(ignorePaths).toContain(
|
|
||||||
`${path.join(worktreeUiRoot, "node_modules", ".vite-temp").replaceAll(path.sep, "/")}/**`,
|
|
||||||
);
|
|
||||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite"));
|
|
||||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite")));
|
|
||||||
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist"));
|
|
||||||
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist")));
|
|
||||||
expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**");
|
|
||||||
expect(ignorePaths).toContain("**/.vite-temp/**");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +1,89 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applyPendingMigrations,
|
||||||
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
agents,
|
agents,
|
||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issues,
|
issues,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
import { runningProcesses } from "../adapters/index.ts";
|
import { runningProcesses } from "../adapters/index.ts";
|
||||||
import { heartbeatService } from "../services/heartbeat.ts";
|
import { heartbeatService } from "../services/heartbeat.ts";
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
type EmbeddedPostgresInstance = {
|
||||||
console.warn(
|
initialise(): Promise<void>;
|
||||||
`Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
start(): Promise<void>;
|
||||||
);
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
|
databaseDir: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, instance, dataDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnAliveProcess() {
|
function spawnAliveProcess() {
|
||||||
|
|
@ -32,14 +92,17 @@ function spawnAliveProcess() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
describe("heartbeat orphaned process recovery", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
const childProcesses = new Set<ChildProcess>();
|
const childProcesses = new Set<ChildProcess>();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-");
|
const started = await startTempDatabase();
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(started.connectionString);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -62,7 +125,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
}
|
}
|
||||||
childProcesses.clear();
|
childProcesses.clear();
|
||||||
runningProcesses.clear();
|
runningProcesses.clear();
|
||||||
await tempDb?.cleanup();
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function seedRunFixture(input?: {
|
async function seedRunFixture(input?: {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown>
|
||||||
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||||
const agentId = "agent-123";
|
const agentId = "agent-123";
|
||||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir({ id: agentId });
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
||||||
|
|
||||||
const result = resolveRuntimeSessionParamsForWorkspace({
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
||||||
agentId,
|
agentId,
|
||||||
|
|
@ -96,7 +96,7 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||||
|
|
||||||
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
|
it("does not migrate when resolved workspace id differs from previous session workspace id", () => {
|
||||||
const agentId = "agent-123";
|
const agentId = "agent-123";
|
||||||
const fallbackCwd = resolveDefaultAgentWorkspaceDir({ id: agentId });
|
const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId);
|
||||||
|
|
||||||
const result = resolveRuntimeSessionParamsForWorkspace({
|
const result = resolveRuntimeSessionParamsForWorkspace({
|
||||||
agentId,
|
agentId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestDatabase,
|
|
||||||
type EmbeddedPostgresTestSupport,
|
|
||||||
} from "@paperclipai/db";
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
|
||||||
|
|
||||||
describe("agentJoinGrantsFromDefaults", () => {
|
|
||||||
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
|
||||||
expect(agentJoinGrantsFromDefaults(null)).toEqual([
|
|
||||||
{
|
|
||||||
permissionKey: "tasks:assign",
|
|
||||||
scope: null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves invite agent grants and appends tasks:assign", () => {
|
|
||||||
expect(
|
|
||||||
agentJoinGrantsFromDefaults({
|
|
||||||
agent: {
|
|
||||||
grants: [
|
|
||||||
{
|
|
||||||
permissionKey: "agents:create",
|
|
||||||
scope: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
permissionKey: "agents:create",
|
|
||||||
scope: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
permissionKey: "tasks:assign",
|
|
||||||
scope: null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate tasks:assign when invite defaults already include it", () => {
|
|
||||||
expect(
|
|
||||||
agentJoinGrantsFromDefaults({
|
|
||||||
agent: {
|
|
||||||
grants: [
|
|
||||||
{
|
|
||||||
permissionKey: "tasks:assign",
|
|
||||||
scope: { projectId: "project-1" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
permissionKey: "tasks:assign",
|
|
||||||
scope: { projectId: "project-1" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -20,29 +20,16 @@ describe("issue goal fallback", () => {
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: null,
|
projectId: null,
|
||||||
goalId: "goal-2",
|
goalId: "goal-2",
|
||||||
projectGoalId: "goal-3",
|
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-2");
|
).toBe("goal-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inherits the project goal when creating a project-linked issue", () => {
|
it("does not force a company goal when the issue belongs to a project", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveIssueGoalId({
|
resolveIssueGoalId({
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
projectGoalId: "goal-2",
|
|
||||||
defaultGoalId: "goal-1",
|
|
||||||
}),
|
|
||||||
).toBe("goal-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not force a company goal when the project has no goal", () => {
|
|
||||||
expect(
|
|
||||||
resolveIssueGoalId({
|
|
||||||
projectId: "project-1",
|
|
||||||
goalId: null,
|
|
||||||
projectGoalId: null,
|
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
@ -53,47 +40,20 @@ describe("issue goal fallback", () => {
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: null,
|
currentGoalId: null,
|
||||||
currentProjectGoalId: null,
|
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-1");
|
).toBe("goal-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches from the company fallback to the project goal when a project is added later", () => {
|
it("clears the fallback when a project is added later", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveNextIssueGoalId({
|
resolveNextIssueGoalId({
|
||||||
currentProjectId: null,
|
currentProjectId: null,
|
||||||
currentGoalId: "goal-1",
|
currentGoalId: "goal-1",
|
||||||
currentProjectGoalId: null,
|
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
goalId: null,
|
goalId: null,
|
||||||
projectGoalId: "goal-2",
|
|
||||||
defaultGoalId: "goal-1",
|
defaultGoalId: "goal-1",
|
||||||
}),
|
}),
|
||||||
).toBe("goal-2");
|
).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
it("backfills the project goal for legacy project-linked issues on update", () => {
|
|
||||||
expect(
|
|
||||||
resolveNextIssueGoalId({
|
|
||||||
currentProjectId: "project-1",
|
|
||||||
currentGoalId: null,
|
|
||||||
currentProjectGoalId: "goal-2",
|
|
||||||
defaultGoalId: "goal-1",
|
|
||||||
}),
|
|
||||||
).toBe("goal-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves an explicit goal across project fallback changes", () => {
|
|
||||||
expect(
|
|
||||||
resolveNextIssueGoalId({
|
|
||||||
currentProjectId: "project-1",
|
|
||||||
currentGoalId: "goal-explicit",
|
|
||||||
currentProjectGoalId: "goal-2",
|
|
||||||
projectId: "project-2",
|
|
||||||
projectGoalId: "goal-3",
|
|
||||||
defaultGoalId: "goal-1",
|
|
||||||
}),
|
|
||||||
).toBe("goal-explicit");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
import express from "express";
|
|
||||||
import request from "supertest";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { issueRoutes } from "../routes/issues.js";
|
|
||||||
import { errorHandler } from "../middleware/index.js";
|
|
||||||
|
|
||||||
const mockIssueService = vi.hoisted(() => ({
|
|
||||||
getById: vi.fn(),
|
|
||||||
getAncestors: vi.fn(),
|
|
||||||
findMentionedProjectIds: vi.fn(),
|
|
||||||
getCommentCursor: vi.fn(),
|
|
||||||
getComment: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockProjectService = vi.hoisted(() => ({
|
|
||||||
getById: vi.fn(),
|
|
||||||
listByIds: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockGoalService = vi.hoisted(() => ({
|
|
||||||
getById: vi.fn(),
|
|
||||||
getDefaultCompanyGoal: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../services/index.js", () => ({
|
|
||||||
accessService: () => ({
|
|
||||||
canUser: vi.fn(),
|
|
||||||
hasPermission: vi.fn(),
|
|
||||||
}),
|
|
||||||
agentService: () => ({
|
|
||||||
getById: vi.fn(),
|
|
||||||
}),
|
|
||||||
documentService: () => ({
|
|
||||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
|
||||||
}),
|
|
||||||
executionWorkspaceService: () => ({
|
|
||||||
getById: vi.fn(),
|
|
||||||
}),
|
|
||||||
goalService: () => mockGoalService,
|
|
||||||
heartbeatService: () => ({
|
|
||||||
wakeup: vi.fn(async () => undefined),
|
|
||||||
reportRunActivity: vi.fn(async () => undefined),
|
|
||||||
}),
|
|
||||||
issueApprovalService: () => ({}),
|
|
||||||
issueService: () => mockIssueService,
|
|
||||||
logActivity: vi.fn(async () => undefined),
|
|
||||||
projectService: () => mockProjectService,
|
|
||||||
routineService: () => ({
|
|
||||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
|
||||||
}),
|
|
||||||
workProductService: () => ({
|
|
||||||
listForIssue: vi.fn(async () => []),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createApp() {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => {
|
|
||||||
(req as any).actor = {
|
|
||||||
type: "board",
|
|
||||||
userId: "local-board",
|
|
||||||
companyIds: ["company-1"],
|
|
||||||
source: "local_implicit",
|
|
||||||
isInstanceAdmin: false,
|
|
||||||
};
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.use("/api", issueRoutes({} as any, {} as any));
|
|
||||||
app.use(errorHandler);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyProjectLinkedIssue = {
|
|
||||||
id: "11111111-1111-4111-8111-111111111111",
|
|
||||||
companyId: "company-1",
|
|
||||||
identifier: "PAP-581",
|
|
||||||
title: "Legacy onboarding task",
|
|
||||||
description: "Seed the first CEO task",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
projectId: "22222222-2222-4222-8222-222222222222",
|
|
||||||
goalId: null,
|
|
||||||
parentId: null,
|
|
||||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
|
||||||
assigneeUserId: null,
|
|
||||||
updatedAt: new Date("2026-03-24T12:00:00Z"),
|
|
||||||
executionWorkspaceId: null,
|
|
||||||
labels: [],
|
|
||||||
labelIds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectGoal = {
|
|
||||||
id: "44444444-4444-4444-8444-444444444444",
|
|
||||||
companyId: "company-1",
|
|
||||||
title: "Launch the company",
|
|
||||||
description: null,
|
|
||||||
level: "company",
|
|
||||||
status: "active",
|
|
||||||
parentId: null,
|
|
||||||
ownerAgentId: null,
|
|
||||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("issue goal context routes", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
|
||||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
|
||||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
|
||||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
|
||||||
totalComments: 0,
|
|
||||||
latestCommentId: null,
|
|
||||||
latestCommentAt: null,
|
|
||||||
});
|
|
||||||
mockIssueService.getComment.mockResolvedValue(null);
|
|
||||||
mockProjectService.getById.mockResolvedValue({
|
|
||||||
id: legacyProjectLinkedIssue.projectId,
|
|
||||||
companyId: "company-1",
|
|
||||||
urlKey: "onboarding",
|
|
||||||
goalId: projectGoal.id,
|
|
||||||
goalIds: [projectGoal.id],
|
|
||||||
goals: [{ id: projectGoal.id, title: projectGoal.title }],
|
|
||||||
name: "Onboarding",
|
|
||||||
description: null,
|
|
||||||
status: "in_progress",
|
|
||||||
leadAgentId: null,
|
|
||||||
targetDate: null,
|
|
||||||
color: null,
|
|
||||||
pauseReason: null,
|
|
||||||
pausedAt: null,
|
|
||||||
executionWorkspacePolicy: null,
|
|
||||||
codebase: {
|
|
||||||
workspaceId: null,
|
|
||||||
repoUrl: null,
|
|
||||||
repoRef: null,
|
|
||||||
defaultRef: null,
|
|
||||||
repoName: null,
|
|
||||||
localFolder: null,
|
|
||||||
managedFolder: "/tmp/company-1/project-1",
|
|
||||||
effectiveLocalFolder: "/tmp/company-1/project-1",
|
|
||||||
origin: "managed_checkout",
|
|
||||||
},
|
|
||||||
workspaces: [],
|
|
||||||
primaryWorkspace: null,
|
|
||||||
archivedAt: null,
|
|
||||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
|
||||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
|
||||||
});
|
|
||||||
mockProjectService.listByIds.mockResolvedValue([]);
|
|
||||||
mockGoalService.getById.mockImplementation(async (id: string) =>
|
|
||||||
id === projectGoal.id ? projectGoal : null,
|
|
||||||
);
|
|
||||||
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
|
||||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.goalId).toBe(projectGoal.id);
|
|
||||||
expect(res.body.goal).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: projectGoal.id,
|
|
||||||
title: projectGoal.title,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
|
||||||
const res = await request(createApp()).get(
|
|
||||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
|
||||||
expect(res.body.goal).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: projectGoal.id,
|
|
||||||
title: projectGoal.title,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +1,103 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
|
applyPendingMigrations,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueInboxArchives,
|
|
||||||
issues,
|
issues,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
import { issueService } from "../services/issues.ts";
|
import { issueService } from "../services/issues.ts";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
type EmbeddedPostgresInstance = {
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
initialise(): Promise<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
console.warn(
|
databaseDir: string;
|
||||||
`Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
user: string;
|
||||||
);
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
}
|
}
|
||||||
|
|
||||||
describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issueService.list participantAgentId", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let svc!: ReturnType<typeof issueService>;
|
let svc!: ReturnType<typeof issueService>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-");
|
const started = await startTempDatabase();
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(started.connectionString);
|
||||||
svc = issueService(db);
|
svc = issueService(db);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issueComments);
|
await db.delete(issueComments);
|
||||||
await db.delete(issueInboxArchives);
|
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
|
|
@ -45,7 +105,10 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await tempDb?.cleanup();
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns issues an agent participated in across the supported signals", async () => {
|
it("returns issues an agent participated in across the supported signals", async () => {
|
||||||
|
|
@ -218,99 +281,4 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
|
|
||||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
|
||||||
const companyId = randomUUID();
|
|
||||||
const userId = "user-1";
|
|
||||||
const otherUserId = "user-2";
|
|
||||||
|
|
||||||
await db.insert(companies).values({
|
|
||||||
id: companyId,
|
|
||||||
name: "Paperclip",
|
|
||||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
||||||
requireBoardApprovalForNewAgents: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleIssueId = randomUUID();
|
|
||||||
const archivedIssueId = randomUUID();
|
|
||||||
const resurfacedIssueId = randomUUID();
|
|
||||||
|
|
||||||
await db.insert(issues).values([
|
|
||||||
{
|
|
||||||
id: visibleIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Visible issue",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
createdByUserId: userId,
|
|
||||||
createdAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: archivedIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Archived issue",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
createdByUserId: userId,
|
|
||||||
createdAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: resurfacedIssueId,
|
|
||||||
companyId,
|
|
||||||
title: "Resurfaced issue",
|
|
||||||
status: "todo",
|
|
||||||
priority: "medium",
|
|
||||||
createdByUserId: userId,
|
|
||||||
createdAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await svc.archiveInbox(
|
|
||||||
companyId,
|
|
||||||
archivedIssueId,
|
|
||||||
userId,
|
|
||||||
new Date("2026-03-26T12:30:00.000Z"),
|
|
||||||
);
|
|
||||||
await svc.archiveInbox(
|
|
||||||
companyId,
|
|
||||||
resurfacedIssueId,
|
|
||||||
userId,
|
|
||||||
new Date("2026-03-26T13:00:00.000Z"),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.insert(issueComments).values({
|
|
||||||
companyId,
|
|
||||||
issueId: resurfacedIssueId,
|
|
||||||
authorUserId: otherUserId,
|
|
||||||
body: "This should bring the issue back into Mine.",
|
|
||||||
createdAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
||||||
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const archivedFiltered = await svc.list(companyId, {
|
|
||||||
touchedByUserId: userId,
|
|
||||||
inboxArchivedByUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
|
|
||||||
resurfacedIssueId,
|
|
||||||
visibleIssueId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
|
|
||||||
|
|
||||||
const afterUnarchive = await svc.list(companyId, {
|
|
||||||
touchedByUserId: userId,
|
|
||||||
inboxArchivedByUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
|
|
||||||
visibleIssueId,
|
|
||||||
archivedIssueId,
|
|
||||||
resurfacedIssueId,
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { normalizeAgentMentionToken } from "../services/issues.ts";
|
|
||||||
|
|
||||||
describe("normalizeAgentMentionToken", () => {
|
|
||||||
it("decodes hex numeric entities such as space ( )", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes decimal numeric entities", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes common named whitespace entities", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba ")).toBe("Baba");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mid-token entity (review asked for this shape); we decode &→&, not strip to "Baba" (that broke M&M).
|
|
||||||
it("decodes a named entity in the middle of the token", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Ba&ba")).toBe("Ba&ba");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes & so agent names with ampersands still match", () => {
|
|
||||||
expect(normalizeAgentMentionToken("M&M")).toBe("M&M");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes additional named entities used in rich text (e.g. ©)", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Agent©Name")).toBe("Agent©Name");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unknown semicolon-terminated named references unchanged", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba¬arealentity;")).toBe("Baba¬arealentity;");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns plain names unchanged", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba")).toBe("Baba");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims after decoding entities", () => {
|
|
||||||
expect(normalizeAgentMentionToken("Baba  ")).toBe("Baba");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
|
@ -7,9 +11,11 @@ import {
|
||||||
activityLog,
|
activityLog,
|
||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
agents,
|
agents,
|
||||||
|
applyPendingMigrations,
|
||||||
companies,
|
companies,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
createDb,
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
|
|
@ -20,10 +26,6 @@ import {
|
||||||
routines,
|
routines,
|
||||||
routineTriggers,
|
routineTriggers,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
import { errorHandler } from "../middleware/index.js";
|
import { errorHandler } from "../middleware/index.js";
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
|
|
||||||
|
|
@ -76,22 +78,82 @@ vi.mock("../services/index.js", async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
type EmbeddedPostgresInstance = {
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
initialise(): Promise<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
console.warn(
|
databaseDir: string;
|
||||||
`Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
user: string;
|
||||||
);
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
}
|
}
|
||||||
|
|
||||||
describeEmbeddedPostgres("routine routes end-to-end", () => {
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routine routes end-to-end", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-");
|
const started = await startTempDatabase();
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(started.connectionString);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -112,7 +174,10 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await tempDb?.cleanup();
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>) {
|
async function createApp(actor: Record<string, unknown>) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { createHmac, randomUUID } from "node:crypto";
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
|
applyPendingMigrations,
|
||||||
companies,
|
companies,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
companySecretVersions,
|
companySecretVersions,
|
||||||
createDb,
|
createDb,
|
||||||
|
ensurePostgresDatabase,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issues,
|
issues,
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -15,29 +21,85 @@ import {
|
||||||
routines,
|
routines,
|
||||||
routineTriggers,
|
routineTriggers,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
|
||||||
getEmbeddedPostgresTestSupport,
|
|
||||||
startEmbeddedPostgresTestDatabase,
|
|
||||||
} from "./helpers/embedded-postgres.js";
|
|
||||||
import { issueService } from "../services/issues.ts";
|
import { issueService } from "../services/issues.ts";
|
||||||
import { routineService } from "../services/routines.ts";
|
import { routineService } from "../services/routines.ts";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
type EmbeddedPostgresInstance = {
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
initialise(): Promise<void>;
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
type EmbeddedPostgresCtor = new (opts: {
|
||||||
console.warn(
|
databaseDir: string;
|
||||||
`Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
user: string;
|
||||||
);
|
password: string;
|
||||||
|
port: number;
|
||||||
|
persistent: boolean;
|
||||||
|
initdbFlags?: string[];
|
||||||
|
onLog?: (message: unknown) => void;
|
||||||
|
onError?: (message: unknown) => void;
|
||||||
|
}) => EmbeddedPostgresInstance;
|
||||||
|
|
||||||
|
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
|
||||||
|
const mod = await import("embedded-postgres");
|
||||||
|
return mod.default as EmbeddedPostgresCtor;
|
||||||
}
|
}
|
||||||
|
|
||||||
describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
async function getAvailablePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = address;
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTempDatabase() {
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-"));
|
||||||
|
const port = await getAvailablePort();
|
||||||
|
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
|
||||||
|
const instance = new EmbeddedPostgres({
|
||||||
|
databaseDir: dataDir,
|
||||||
|
user: "paperclip",
|
||||||
|
password: "paperclip",
|
||||||
|
port,
|
||||||
|
persistent: true,
|
||||||
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
|
onLog: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
await instance.initialise();
|
||||||
|
await instance.start();
|
||||||
|
|
||||||
|
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||||
|
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
return { connectionString, dataDir, instance };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routine service live-execution coalescing", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let instance: EmbeddedPostgresInstance | null = null;
|
||||||
|
let dataDir = "";
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
|
const started = await startTempDatabase();
|
||||||
db = createDb(tempDb.connectionString);
|
db = createDb(started.connectionString);
|
||||||
|
instance = started.instance;
|
||||||
|
dataDir = started.dataDir;
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -55,7 +117,10 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await tempDb?.cleanup();
|
await instance?.stop();
|
||||||
|
if (dataDir) {
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function seedFixture(opts?: {
|
async function seedFixture(opts?: {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,7 +13,6 @@ import {
|
||||||
stopRuntimeServicesForExecutionWorkspace,
|
stopRuntimeServicesForExecutionWorkspace,
|
||||||
type RealizedExecutionWorkspace,
|
type RealizedExecutionWorkspace,
|
||||||
} from "../services/workspace-runtime.ts";
|
} from "../services/workspace-runtime.ts";
|
||||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
|
||||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||||
|
|
||||||
|
|
@ -126,7 +124,6 @@ afterEach(async () => {
|
||||||
delete process.env.PAPERCLIP_CONFIG;
|
delete process.env.PAPERCLIP_CONFIG;
|
||||||
delete process.env.PAPERCLIP_HOME;
|
delete process.env.PAPERCLIP_HOME;
|
||||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
delete process.env.PAPERCLIP_WORKTREES_DIR;
|
|
||||||
delete process.env.DATABASE_URL;
|
delete process.env.DATABASE_URL;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -285,156 +282,6 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => {
|
|
||||||
const repoRoot = await createTempRepo();
|
|
||||||
const previousCwd = process.cwd();
|
|
||||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-"));
|
|
||||||
const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-"));
|
|
||||||
const instanceId = "worktree-base";
|
|
||||||
const sharedConfigDir = path.join(paperclipHome, "instances", instanceId);
|
|
||||||
const sharedConfigPath = path.join(sharedConfigDir, "config.json");
|
|
||||||
const sharedEnvPath = path.join(sharedConfigDir, ".env");
|
|
||||||
|
|
||||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
||||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
|
||||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome;
|
|
||||||
|
|
||||||
await fs.mkdir(sharedConfigDir, { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
sharedConfigPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
$meta: {
|
|
||||||
version: 1,
|
|
||||||
updatedAt: "2026-03-26T00:00:00.000Z",
|
|
||||||
source: "doctor",
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(sharedConfigDir, "db"),
|
|
||||||
embeddedPostgresPort: 54329,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(sharedConfigDir, "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file",
|
|
||||||
logDir: path.join(sharedConfigDir, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3100,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
baseUrlMode: "auto",
|
|
||||||
disableSignUp: false,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.join(sharedConfigDir, "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: "local_encrypted",
|
|
||||||
strictMode: false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.join(sharedConfigDir, "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8");
|
|
||||||
|
|
||||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
|
||||||
await fs.copyFile(
|
|
||||||
fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)),
|
|
||||||
path.join(repoRoot, "scripts", "provision-worktree.sh"),
|
|
||||||
);
|
|
||||||
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
|
|
||||||
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
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-worktree.sh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
issue: {
|
|
||||||
id: "issue-1",
|
|
||||||
identifier: "PAP-885",
|
|
||||||
title: "Show worktree banner",
|
|
||||||
},
|
|
||||||
agent: {
|
|
||||||
id: "agent-1",
|
|
||||||
name: "Codex Coder",
|
|
||||||
companyId: "company-1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const configPath = path.join(workspace.cwd, ".paperclip", "config.json");
|
|
||||||
const envPath = path.join(workspace.cwd, ".paperclip", ".env");
|
|
||||||
const envContents = await fs.readFile(envPath, "utf8");
|
|
||||||
const configContents = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
||||||
const configStats = await fs.lstat(configPath);
|
|
||||||
const expectedInstanceId = "pap-885-show-worktree-banner";
|
|
||||||
const expectedInstanceRoot = path.join(
|
|
||||||
isolatedWorktreeHome,
|
|
||||||
"instances",
|
|
||||||
expectedInstanceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(configStats.isSymbolicLink()).toBe(false);
|
|
||||||
expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db"));
|
|
||||||
expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db"));
|
|
||||||
expect(configContents.server.port).not.toBe(3100);
|
|
||||||
expect(configContents.secrets.localEncrypted.keyFilePath).toBe(
|
|
||||||
path.join(expectedInstanceRoot, "secrets", "master.key"),
|
|
||||||
);
|
|
||||||
expect(envContents).not.toContain("DATABASE_URL=");
|
|
||||||
expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`);
|
|
||||||
expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`);
|
|
||||||
expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`);
|
|
||||||
expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true");
|
|
||||||
expect(envContents).toContain(
|
|
||||||
`PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(workspace.cwd);
|
|
||||||
expect(resolvePaperclipConfigPath()).toBe(configPath);
|
|
||||||
} finally {
|
|
||||||
process.chdir(previousCwd);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
it("records worktree setup and provision operations when a recorder is provided", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||||
|
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
applyRuntimePortSelectionToConfig,
|
|
||||||
maybePersistWorktreeRuntimePorts,
|
|
||||||
maybeRepairLegacyWorktreeConfigAndEnvFiles,
|
|
||||||
} from "../worktree-config.js";
|
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
|
||||||
const ORIGINAL_CWD = process.cwd();
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildLegacyConfig(sharedRoot: string) {
|
|
||||||
return {
|
|
||||||
$meta: {
|
|
||||||
version: 1,
|
|
||||||
updatedAt: "2026-03-26T00:00:00.000Z",
|
|
||||||
source: "configure",
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres" as const,
|
|
||||||
embeddedPostgresDataDir: path.join(sharedRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54329,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(sharedRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file" as const,
|
|
||||||
logDir: path.join(sharedRoot, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted" as const,
|
|
||||||
exposure: "private" as const,
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3100,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
baseUrlMode: "explicit" as const,
|
|
||||||
publicBaseUrl: "http://127.0.0.1:3100",
|
|
||||||
disableSignUp: false,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk" as const,
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.join(sharedRoot, "data", "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: "local_encrypted" as const,
|
|
||||||
strictMode: false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.join(sharedRoot, "secrets", "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("worktree config repair", () => {
|
|
||||||
it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => {
|
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-"));
|
|
||||||
const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component");
|
|
||||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
||||||
const configPath = path.join(paperclipDir, "config.json");
|
|
||||||
const envPath = path.join(paperclipDir, ".env");
|
|
||||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
|
||||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
|
|
||||||
await fs.mkdir(paperclipDir, { recursive: true });
|
|
||||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
|
||||||
await fs.writeFile(
|
|
||||||
envPath,
|
|
||||||
[
|
|
||||||
"# Paperclip environment variables",
|
|
||||||
"PAPERCLIP_IN_WORKTREE=true",
|
|
||||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
|
||||||
"PAPERCLIP_AGENT_JWT_SECRET=shared-secret",
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(worktreeRoot);
|
|
||||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
||||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
||||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
||||||
delete process.env.PAPERCLIP_HOME;
|
|
||||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
||||||
delete process.env.PAPERCLIP_CONFIG;
|
|
||||||
delete process.env.PAPERCLIP_CONTEXT;
|
|
||||||
|
|
||||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
repairedConfig: true,
|
|
||||||
repairedEnv: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
||||||
const repairedEnv = await fs.readFile(envPath, "utf8");
|
|
||||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
|
||||||
|
|
||||||
expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
|
||||||
expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
|
||||||
expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
|
||||||
expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
|
||||||
expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
|
||||||
expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`);
|
|
||||||
expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"');
|
|
||||||
expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`);
|
|
||||||
expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`);
|
|
||||||
expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"');
|
|
||||||
expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome);
|
|
||||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("avoids sibling worktree ports when repairing legacy configs", async () => {
|
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
|
|
||||||
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
|
|
||||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
||||||
const configPath = path.join(paperclipDir, "config.json");
|
|
||||||
const envPath = path.join(paperclipDir, ".env");
|
|
||||||
const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default");
|
|
||||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
||||||
|
|
||||||
await fs.mkdir(paperclipDir, { recursive: true });
|
|
||||||
await fs.mkdir(siblingInstanceRoot, { recursive: true });
|
|
||||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8");
|
|
||||||
await fs.writeFile(
|
|
||||||
envPath,
|
|
||||||
[
|
|
||||||
"# Paperclip environment variables",
|
|
||||||
"PAPERCLIP_IN_WORKTREE=true",
|
|
||||||
"PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature",
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(siblingInstanceRoot, "config.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
...buildLegacyConfig(siblingInstanceRoot),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54330,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3101,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(worktreeRoot);
|
|
||||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
||||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature";
|
|
||||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
||||||
|
|
||||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
||||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
||||||
|
|
||||||
expect(result.repairedConfig).toBe(true);
|
|
||||||
expect(repairedConfig.server.port).toBe(3102);
|
|
||||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rebalances duplicate ports for already isolated worktree configs", async () => {
|
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-"));
|
|
||||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
|
|
||||||
const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
|
||||||
const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
||||||
const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component");
|
|
||||||
const paperclipDir = path.join(currentWorktreeRoot, ".paperclip");
|
|
||||||
const configPath = path.join(paperclipDir, "config.json");
|
|
||||||
const envPath = path.join(paperclipDir, ".env");
|
|
||||||
const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component");
|
|
||||||
const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json");
|
|
||||||
|
|
||||||
await fs.mkdir(paperclipDir, { recursive: true });
|
|
||||||
await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
...buildLegacyConfig(currentInstanceRoot),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54330,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(currentInstanceRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file",
|
|
||||||
logDir: path.join(currentInstanceRoot, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3101,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.join(currentInstanceRoot, "data", "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: "local_encrypted",
|
|
||||||
strictMode: false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
envPath,
|
|
||||||
[
|
|
||||||
"# Paperclip environment variables",
|
|
||||||
"PAPERCLIP_IN_WORKTREE=true",
|
|
||||||
"PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component",
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
siblingConfigPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
...buildLegacyConfig(siblingInstanceRoot),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54330,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(siblingInstanceRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3101,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(currentWorktreeRoot);
|
|
||||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
||||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
|
||||||
process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome;
|
|
||||||
|
|
||||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
||||||
const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
||||||
|
|
||||||
expect(result.repairedConfig).toBe(true);
|
|
||||||
expect(repairedConfig.server.port).toBe(3102);
|
|
||||||
expect(repairedConfig.database.embeddedPostgresPort).toBe(54331);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists runtime-selected worktree ports back into config", async () => {
|
|
||||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-"));
|
|
||||||
const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox");
|
|
||||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
|
||||||
const configPath = path.join(paperclipDir, "config.json");
|
|
||||||
const isolatedHome = path.join(tempRoot, ".paperclip-worktrees");
|
|
||||||
const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox");
|
|
||||||
|
|
||||||
await fs.mkdir(paperclipDir, { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
configPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
...buildLegacyConfig(instanceRoot),
|
|
||||||
database: {
|
|
||||||
mode: "embedded-postgres",
|
|
||||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
|
||||||
embeddedPostgresPort: 54331,
|
|
||||||
backup: {
|
|
||||||
enabled: true,
|
|
||||||
intervalMinutes: 60,
|
|
||||||
retentionDays: 30,
|
|
||||||
dir: path.join(instanceRoot, "data", "backups"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
mode: "file",
|
|
||||||
logDir: path.join(instanceRoot, "logs"),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
deploymentMode: "local_trusted",
|
|
||||||
exposure: "private",
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 3101,
|
|
||||||
allowedHostnames: [],
|
|
||||||
serveUi: true,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
provider: "local_disk",
|
|
||||||
localDisk: {
|
|
||||||
baseDir: path.join(instanceRoot, "data", "storage"),
|
|
||||||
},
|
|
||||||
s3: {
|
|
||||||
bucket: "paperclip",
|
|
||||||
region: "us-east-1",
|
|
||||||
prefix: "",
|
|
||||||
forcePathStyle: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secrets: {
|
|
||||||
provider: "local_encrypted",
|
|
||||||
strictMode: false,
|
|
||||||
localEncrypted: {
|
|
||||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
) + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
process.chdir(worktreeRoot);
|
|
||||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
|
||||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
|
|
||||||
process.env.PAPERCLIP_HOME = isolatedHome;
|
|
||||||
process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox";
|
|
||||||
process.env.PAPERCLIP_CONFIG = configPath;
|
|
||||||
|
|
||||||
maybePersistWorktreeRuntimePorts({
|
|
||||||
serverPort: 3103,
|
|
||||||
databasePort: 54335,
|
|
||||||
});
|
|
||||||
|
|
||||||
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
|
||||||
|
|
||||||
expect(writtenConfig.server.port).toBe(3103);
|
|
||||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54335);
|
|
||||||
expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can update the in-memory config without rewriting env-driven ports", () => {
|
|
||||||
const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), {
|
|
||||||
serverPort: 3104,
|
|
||||||
databasePort: 54340,
|
|
||||||
allowServerPortWrite: false,
|
|
||||||
allowDatabasePortWrite: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(changed).toBe(true);
|
|
||||||
expect(config.server.port).toBe(3100);
|
|
||||||
expect(config.database.embeddedPostgresPort).toBe(54340);
|
|
||||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { existsSync, realpathSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||||
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
|
||||||
import {
|
import {
|
||||||
AUTH_BASE_URL_MODES,
|
AUTH_BASE_URL_MODES,
|
||||||
DEPLOYMENT_EXPOSURES,
|
DEPLOYMENT_EXPOSURES,
|
||||||
|
|
@ -37,8 +36,6 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
||||||
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
|
||||||
|
|
||||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
function toGlobstarPath(candidate: string): string {
|
|
||||||
return `${candidate.replaceAll(path.sep, "/")}/**`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addIgnorePath(target: Set<string>, candidate: string): void {
|
|
||||||
target.add(candidate);
|
|
||||||
target.add(toGlobstarPath(candidate));
|
|
||||||
try {
|
|
||||||
const realPath = fs.realpathSync(candidate);
|
|
||||||
target.add(realPath);
|
|
||||||
target.add(toGlobstarPath(realPath));
|
|
||||||
} catch {
|
|
||||||
// Ignore paths that do not exist in the current checkout.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
|
|
||||||
const ignorePaths = new Set<string>([
|
|
||||||
"**/{node_modules,bower_components,vendor}/**",
|
|
||||||
"**/.vite-temp/**",
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const relativePath of [
|
|
||||||
"../ui/node_modules",
|
|
||||||
"../ui/node_modules/.vite-temp",
|
|
||||||
"../ui/.vite",
|
|
||||||
"../ui/dist",
|
|
||||||
]) {
|
|
||||||
addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...ignorePaths];
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
|
@ -13,25 +12,7 @@ function expandHomePrefix(value: string): string {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [nexus] Read ~/.nexus pointer file for custom home directory
|
|
||||||
function resolveNexusPointerFile(): string | null {
|
|
||||||
const pointerPath = path.resolve(os.homedir(), ".nexus");
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(pointerPath, "utf-8").trim();
|
|
||||||
if (raw.length > 0) {
|
|
||||||
return path.resolve(expandHomePrefix(raw));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ~/.nexus does not exist or is unreadable — fall through
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolvePaperclipHomeDir(): string {
|
export function resolvePaperclipHomeDir(): string {
|
||||||
// [nexus] Pointer-file: ~/.nexus overrides all other home resolution
|
|
||||||
const nexusRoot = resolveNexusPointerFile();
|
|
||||||
if (nexusRoot) return nexusRoot;
|
|
||||||
|
|
||||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||||
return path.resolve(os.homedir(), ".paperclip");
|
return path.resolve(os.homedir(), ".paperclip");
|
||||||
|
|
@ -73,13 +54,12 @@ export function resolveDefaultBackupDir(): string {
|
||||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
|
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
|
||||||
}
|
}
|
||||||
|
|
||||||
// [nexus] Accept agent object for human-readable slugified workspace dirs
|
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
||||||
export function resolveDefaultAgentWorkspaceDir(agent: { id: string; name?: string | null }): string {
|
const trimmed = agentId.trim();
|
||||||
// Use slugified name for human-readable dirs; fall back to sanitized id
|
if (!PATH_SEGMENT_RE.test(trimmed)) {
|
||||||
const segment = agent.name?.trim()
|
throw new Error(`Invalid agent id for workspace path '${agentId}'.`);
|
||||||
? sanitizeFriendlyPathSegment(agent.name, agent.id)
|
}
|
||||||
: sanitizeFriendlyPathSegment(agent.id, agent.id);
|
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
|
||||||
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", segment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {
|
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createDb,
|
createDb,
|
||||||
ensurePostgresDatabase,
|
ensurePostgresDatabase,
|
||||||
formatEmbeddedPostgresError,
|
|
||||||
getPostgresDataDirectory,
|
getPostgresDataDirectory,
|
||||||
inspectMigrations,
|
inspectMigrations,
|
||||||
applyPendingMigrations,
|
applyPendingMigrations,
|
||||||
createEmbeddedPostgresLogBuffer,
|
|
||||||
reconcilePendingMigrationHistory,
|
reconcilePendingMigrationHistory,
|
||||||
formatDatabaseBackupResult,
|
formatDatabaseBackupResult,
|
||||||
runDatabaseBackup,
|
runDatabaseBackup,
|
||||||
|
|
@ -32,7 +30,6 @@ import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineSe
|
||||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||||
import { printStartupBanner } from "./startup-banner.js";
|
import { printStartupBanner } from "./startup-banner.js";
|
||||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||||
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
|
|
||||||
|
|
||||||
type BetterAuthSessionUser = {
|
type BetterAuthSessionUser = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -72,7 +69,7 @@ export interface StartedServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(): Promise<StartedServer> {
|
export async function startServer(): Promise<StartedServer> {
|
||||||
let config = loadConfig();
|
const config = loadConfig();
|
||||||
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
||||||
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
|
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
|
||||||
}
|
}
|
||||||
|
|
@ -97,8 +94,8 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
|
async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
|
||||||
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
|
|
||||||
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
|
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
|
||||||
|
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
|
||||||
if (!stdin.isTTY || !stdout.isTTY) return true;
|
if (!stdin.isTTY || !stdout.isTTY) return true;
|
||||||
|
|
||||||
const prompt = createInterface({ input: stdin, output: stdout });
|
const prompt = createInterface({ input: stdin, output: stdout });
|
||||||
|
|
@ -170,22 +167,10 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
const normalized = host.trim().toLowerCase();
|
const normalized = host.trim().toLowerCase();
|
||||||
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_BOARD_USER_ID = "local-board";
|
const LOCAL_BOARD_USER_ID = "local-board";
|
||||||
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
|
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
|
||||||
const LOCAL_BOARD_USER_NAME = "Owner"; // [nexus] was: "Board"
|
const LOCAL_BOARD_USER_NAME = "Board";
|
||||||
|
|
||||||
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
|
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -248,7 +233,6 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
let embeddedPostgresStartedByThisProcess = false;
|
let embeddedPostgresStartedByThisProcess = false;
|
||||||
let migrationSummary: MigrationSummary = "skipped";
|
let migrationSummary: MigrationSummary = "skipped";
|
||||||
let activeDatabaseConnectionString: string;
|
let activeDatabaseConnectionString: string;
|
||||||
let resolvedEmbeddedPostgresPort: number | null = null;
|
|
||||||
let startupDbInfo:
|
let startupDbInfo:
|
||||||
| { mode: "external-postgres"; connectionString: string }
|
| { mode: "external-postgres"; connectionString: string }
|
||||||
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
| { mode: "embedded-postgres"; dataDir: string; port: number };
|
||||||
|
|
@ -274,31 +258,29 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
const dataDir = resolve(config.embeddedPostgresDataDir);
|
const dataDir = resolve(config.embeddedPostgresDataDir);
|
||||||
const configuredPort = config.embeddedPostgresPort;
|
const configuredPort = config.embeddedPostgresPort;
|
||||||
let port = configuredPort;
|
let port = configuredPort;
|
||||||
const logBuffer = createEmbeddedPostgresLogBuffer(120);
|
const embeddedPostgresLogBuffer: string[] = [];
|
||||||
|
const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120;
|
||||||
const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true";
|
const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true";
|
||||||
const appendEmbeddedPostgresLog = (message: unknown) => {
|
const appendEmbeddedPostgresLog = (message: unknown) => {
|
||||||
logBuffer.append(message);
|
const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? "");
|
||||||
if (!verboseEmbeddedPostgresLogs) {
|
for (const lineRaw of text.split(/\r?\n/)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = typeof message === "string"
|
|
||||||
? message.split(/\r?\n/)
|
|
||||||
: message instanceof Error
|
|
||||||
? [message.message]
|
|
||||||
: [String(message ?? "")];
|
|
||||||
for (const lineRaw of lines) {
|
|
||||||
const line = lineRaw.trim();
|
const line = lineRaw.trim();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
|
embeddedPostgresLogBuffer.push(line);
|
||||||
|
if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) {
|
||||||
|
embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT);
|
||||||
|
}
|
||||||
|
if (verboseEmbeddedPostgresLogs) {
|
||||||
|
logger.info({ embeddedPostgresLog: line }, "embedded-postgres");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => {
|
const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => {
|
||||||
const recentLogs = logBuffer.getRecentLogs();
|
if (embeddedPostgresLogBuffer.length > 0) {
|
||||||
if (recentLogs.length > 0) {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
phase,
|
phase,
|
||||||
recentLogs,
|
recentLogs: embeddedPostgresLogBuffer,
|
||||||
err,
|
err,
|
||||||
},
|
},
|
||||||
"Embedded PostgreSQL failed; showing buffered startup logs",
|
"Embedded PostgreSQL failed; showing buffered startup logs",
|
||||||
|
|
@ -365,7 +347,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
password: "paperclip",
|
password: "paperclip",
|
||||||
port,
|
port,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||||
onLog: appendEmbeddedPostgresLog,
|
onLog: appendEmbeddedPostgresLog,
|
||||||
onError: appendEmbeddedPostgresLog,
|
onError: appendEmbeddedPostgresLog,
|
||||||
});
|
});
|
||||||
|
|
@ -375,10 +357,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
await embeddedPostgres.initialise();
|
await embeddedPostgres.initialise();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logEmbeddedPostgresFailure("initialise", err);
|
logEmbeddedPostgresFailure("initialise", err);
|
||||||
throw formatEmbeddedPostgresError(err, {
|
throw err;
|
||||||
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
|
logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`);
|
||||||
|
|
@ -392,10 +371,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
await embeddedPostgres.start();
|
await embeddedPostgres.start();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logEmbeddedPostgresFailure("start", err);
|
logEmbeddedPostgresFailure("start", err);
|
||||||
throw formatEmbeddedPostgresError(err, {
|
throw err;
|
||||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
|
||||||
recentLogs: logBuffer.getRecentLogs(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
embeddedPostgresStartedByThisProcess = true;
|
embeddedPostgresStartedByThisProcess = true;
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +395,6 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
db = createDb(embeddedConnectionString);
|
db = createDb(embeddedConnectionString);
|
||||||
logger.info("Embedded PostgreSQL ready");
|
logger.info("Embedded PostgreSQL ready");
|
||||||
activeDatabaseConnectionString = embeddedConnectionString;
|
activeDatabaseConnectionString = embeddedConnectionString;
|
||||||
resolvedEmbeddedPostgresPort = port;
|
|
||||||
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,19 +476,6 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenPort = await detectPort(config.port);
|
const listenPort = await detectPort(config.port);
|
||||||
if (listenPort !== config.port) {
|
|
||||||
config.port = listenPort;
|
|
||||||
}
|
|
||||||
if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) {
|
|
||||||
config.embeddedPostgresPort = resolvedEmbeddedPostgresPort;
|
|
||||||
}
|
|
||||||
if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) {
|
|
||||||
config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort);
|
|
||||||
}
|
|
||||||
maybePersistWorktreeRuntimePorts({
|
|
||||||
serverPort: listenPort,
|
|
||||||
databasePort: resolvedEmbeddedPostgresPort,
|
|
||||||
});
|
|
||||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||||
const storageService = createStorageServiceFromConfig(config);
|
const storageService = createStorageServiceFromConfig(config);
|
||||||
const app = await createApp(db as any, {
|
const app = await createApp(db as any, {
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,24 @@
|
||||||
<!-- [nexus] rewritten -->
|
You are the CEO.
|
||||||
You are the Project Manager for this Nexus workspace.
|
|
||||||
|
|
||||||
Your home directory is $AGENT_HOME. Everything personal to you — memory, notes, plans — lives there. Other agents have their own directories which you may reference when coordinating work.
|
Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary.
|
||||||
|
|
||||||
Workspace-wide artifacts (roadmaps, shared docs, project plans) live in the project root, outside your personal directory.
|
Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory.
|
||||||
|
|
||||||
## Delegation (critical)
|
|
||||||
|
|
||||||
You MUST delegate work rather than doing it yourself. When a task is assigned to you:
|
|
||||||
|
|
||||||
1. **Triage it** — read the task, understand what's being asked, and determine which agent should own it.
|
|
||||||
2. **Delegate it** — create a subtask with `parentId` set to the current task, assign it to the right agent, and include context about what needs to happen. Routing rules:
|
|
||||||
- **Code, bugs, features, tests, technical implementation** → Engineer agent
|
|
||||||
- **Cross-functional or unclear** → break into separate subtasks per domain
|
|
||||||
- If no suitable agent exists, use the `nexus-create-agent` skill to add one before delegating.
|
|
||||||
3. **Do NOT write code, implement features, or fix bugs yourself.** Your agents exist for this.
|
|
||||||
4. **Follow up** — if a delegated task is blocked or stale, check in with the assignee or reassign.
|
|
||||||
|
|
||||||
## What You DO Personally
|
|
||||||
|
|
||||||
- Set priorities and make planning decisions
|
|
||||||
- Resolve cross-agent conflicts or ambiguity
|
|
||||||
- Communicate status to the Owner
|
|
||||||
- Approve or reject proposals from agents
|
|
||||||
- Add new agents when the workspace needs capacity
|
|
||||||
- Unblock agents when they escalate to you
|
|
||||||
- Update workspace branding and settings (you have elevated permissions as the primary PM)
|
|
||||||
|
|
||||||
## Keeping Work Moving
|
|
||||||
|
|
||||||
- Don't let tasks sit idle. If you delegated something, check it's progressing.
|
|
||||||
- If an agent is blocked, help unblock them — escalate to the Owner if needed.
|
|
||||||
- You must always update your task with a comment explaining what you did.
|
|
||||||
|
|
||||||
## Memory and Planning
|
## Memory and Planning
|
||||||
|
|
||||||
Use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans.
|
You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions.
|
||||||
|
|
||||||
Invoke it whenever you need to remember, retrieve, or organize anything.
|
Invoke it whenever you need to remember, retrieve, or organize anything.
|
||||||
|
|
||||||
## Safety Considerations
|
## Safety Considerations
|
||||||
|
|
||||||
- Never exfiltrate secrets or private data.
|
- Never exfiltrate secrets or private data.
|
||||||
- Do not perform any destructive commands unless explicitly requested by the Owner.
|
- Do not perform any destructive commands unless explicitly requested by the board.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
Read these files on every heartbeat:
|
These files are essential. Read them.
|
||||||
|
|
||||||
- `$AGENT_HOME/HEARTBEAT.md` — task loop checklist
|
- `$AGENT_HOME/HEARTBEAT.md` -- execution and extraction checklist. Run every heartbeat.
|
||||||
- `$AGENT_HOME/SOUL.md` — your identity and how to act
|
- `$AGENT_HOME/SOUL.md` -- who you are and how you should act.
|
||||||
- `$AGENT_HOME/TOOLS.md` — tools you have access to
|
- `$AGENT_HOME/TOOLS.md` -- tools you have access to
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,72 @@
|
||||||
<!-- [nexus] rewritten -->
|
# HEARTBEAT.md -- CEO Heartbeat Checklist
|
||||||
# HEARTBEAT.md -- Project Manager Task Loop
|
|
||||||
|
|
||||||
Run this checklist on every heartbeat.
|
Run this checklist on every heartbeat. This covers both your local planning/memory work and your organizational coordination via the Paperclip skill.
|
||||||
|
|
||||||
## 1. Identity and Context
|
## 1. Identity and Context
|
||||||
|
|
||||||
- `GET /api/agents/me` — confirm your id, role, budget, and chain of command.
|
- `GET /api/agents/me` -- confirm your id, role, budget, chainOfCommand.
|
||||||
- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`.
|
- Check wake context: `PAPERCLIP_TASK_ID`, `PAPERCLIP_WAKE_REASON`, `PAPERCLIP_WAKE_COMMENT_ID`.
|
||||||
|
|
||||||
## 2. Review Active Work
|
## 2. Local Planning Check
|
||||||
|
|
||||||
1. Check your active tasks: `GET /api/companies/{workspaceId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
|
1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan".
|
||||||
2. Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
|
2. Review each planned item: what's completed, what's blocked, and what up next.
|
||||||
3. If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
|
3. For any blockers, resolve them yourself or escalate to the board.
|
||||||
|
4. If you're ahead, start on the next highest priority.
|
||||||
|
5. Record progress updates in the daily notes.
|
||||||
|
|
||||||
## 3. Triage and Delegate
|
## 3. Approval Follow-Up
|
||||||
|
|
||||||
For each task assigned to you:
|
|
||||||
|
|
||||||
1. Read the task, understand the requirements and acceptance criteria.
|
|
||||||
2. Identify the right agent to implement it.
|
|
||||||
3. Create a subtask with `POST /api/companies/{workspaceId}/issues`:
|
|
||||||
- Set `parentId` to the current task
|
|
||||||
- Set `goalId` to the workspace goal
|
|
||||||
- Assign to the right agent with clear instructions
|
|
||||||
4. Comment on your task explaining who you delegated to and why.
|
|
||||||
|
|
||||||
## 4. Approval Follow-Up
|
|
||||||
|
|
||||||
If `PAPERCLIP_APPROVAL_ID` is set:
|
If `PAPERCLIP_APPROVAL_ID` is set:
|
||||||
|
|
||||||
- Review the approval and its linked tasks.
|
- Review the approval and its linked issues.
|
||||||
- Close resolved tasks or comment on what remains open.
|
- Close resolved issues or comment on what remains open.
|
||||||
|
|
||||||
## 5. Check on Delegated Work
|
## 4. Get Assignments
|
||||||
|
|
||||||
- Review tasks delegated to other agents. Are they progressing?
|
- `GET /api/companies/{companyId}/issues?assigneeAgentId={your-id}&status=todo,in_progress,blocked`
|
||||||
- If blocked or stale, add a comment requesting an update or help unblock.
|
- Prioritize: `in_progress` first, then `todo`. Skip `blocked` unless you can unblock it.
|
||||||
- Escalate to the Owner if a blocker is external or requires a decision.
|
- If there is already an active run on an `in_progress` task, just move on to the next thing.
|
||||||
|
- If `PAPERCLIP_TASK_ID` is set and assigned to you, prioritize that task.
|
||||||
|
|
||||||
## 6. Status Update
|
## 5. Checkout and Work
|
||||||
|
|
||||||
- Comment on in-progress work before exiting.
|
- Always checkout before working: `POST /api/issues/{id}/checkout`.
|
||||||
- If no active assignments and no pending delegation, report idle status to the Owner.
|
- Never retry a 409 -- that task belongs to someone else.
|
||||||
|
- Do the work. Update status and comment when done.
|
||||||
|
|
||||||
|
## 6. Delegation
|
||||||
|
|
||||||
|
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`.
|
||||||
|
- Use `paperclip-create-agent` skill when hiring new agents.
|
||||||
|
- Assign work to the right agent for the job.
|
||||||
|
|
||||||
|
## 7. Fact Extraction
|
||||||
|
|
||||||
|
1. Check for new conversations since last extraction.
|
||||||
|
2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA).
|
||||||
|
3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries.
|
||||||
|
4. Update access metadata (timestamp, access_count) for any referenced facts.
|
||||||
|
|
||||||
|
## 8. Exit
|
||||||
|
|
||||||
|
- Comment on any in_progress work before exiting.
|
||||||
|
- If no assignments and no valid mention-handoff, exit cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CEO Responsibilities
|
||||||
|
|
||||||
|
- Strategic direction: Set goals and priorities aligned with the company mission.
|
||||||
|
- Hiring: Spin up new agents when capacity is needed.
|
||||||
|
- Unblocking: Escalate or resolve blockers for reports.
|
||||||
|
- Budget awareness: Above 80% spend, focus only on critical tasks.
|
||||||
|
- Never look for unassigned work -- only work on what is assigned to you.
|
||||||
|
- Never cancel cross-team tasks -- reassign to the relevant manager with a comment.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Always checkout before working: `POST /api/issues/{id}/checkout`
|
- Always use the Paperclip skill for coordination.
|
||||||
- Never retry a 409 — that task belongs to someone else.
|
|
||||||
- Always include `X-Paperclip-Run-Id` header on mutating API calls.
|
- Always include `X-Paperclip-Run-Id` header on mutating API calls.
|
||||||
- Comment in concise markdown: status line + bullets + links.
|
- Comment in concise markdown: status line + bullets + links.
|
||||||
- Self-assign via checkout only when explicitly @-mentioned.
|
- Self-assign via checkout only when explicitly @-mentioned.
|
||||||
- Never look for unassigned work — only work on what is assigned to you.
|
|
||||||
- Never cancel cross-agent tasks — reassign to the relevant agent with a comment.
|
|
||||||
|
|
||||||
## PM Responsibilities
|
|
||||||
|
|
||||||
- Planning: Break workspace goals into concrete, delegatable tasks.
|
|
||||||
- Coordination: Keep agents unblocked and work flowing.
|
|
||||||
- Reporting: Keep the Owner informed of progress and blockers.
|
|
||||||
- Capacity: Add agents when the workspace needs more execution power.
|
|
||||||
- Budget awareness: Above 80% budget spend, focus only on critical tasks.
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,33 @@
|
||||||
<!-- [nexus] rewritten -->
|
# SOUL.md -- CEO Persona
|
||||||
# SOUL.md -- Project Manager Persona
|
|
||||||
|
|
||||||
You are the Project Manager for this Nexus workspace.
|
You are the CEO.
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Your job is to orchestrate work — not to write code or implement features yourself. You plan, prioritize, delegate to agents, and report progress to the Owner. You are the connective tissue between the Owner's goals and execution.
|
|
||||||
|
|
||||||
## Strategic Posture
|
## Strategic Posture
|
||||||
|
|
||||||
- You own the plan. Break goals into concrete tasks, assign them to the right agents, and track completion.
|
- You own the P&L. Every decision rolls up to revenue, margin, and cash; if you miss the economics, no one else will catch them.
|
||||||
- Default to clarity. An ambiguous task is a blocked task. Write clear acceptance criteria before delegating.
|
- Default to action. Ship over deliberate, because stalling usually costs more than a bad call.
|
||||||
- Hold the long view while executing the near term. Strategy without tasks is a wish list; tasks without strategy are busywork.
|
- Hold the long view while executing the near term. Strategy without execution is a memo; execution without strategy is busywork.
|
||||||
- Protect the team's focus. Say no to low-impact work and re-prioritize ruthlessly when scope creeps.
|
- Protect focus hard. Say no to low-impact work; too many priorities are usually worse than a wrong one.
|
||||||
- In trade-offs, optimize for progress and reversibility. Ship something over planning forever.
|
- In trade-offs, optimize for learning speed and reversibility. Move fast on two-way doors; slow down on one-way doors.
|
||||||
- Keep the Owner informed. Dashboards help, but a brief status update beats a silent dashboard.
|
- Know the numbers cold. Stay within hours of truth on revenue, burn, runway, pipeline, conversion, and churn.
|
||||||
- Think in constraints. Ask "what do we stop?" before "what do we add?"
|
- Treat every dollar, headcount, and engineering hour as a bet. Know the thesis and expected return.
|
||||||
- Avoid work vacuums. If an agent is idle and work exists, find them the right task.
|
- Think in constraints, not wishes. Ask "what do we stop?" before "what do we add?"
|
||||||
- Pull for bad news and reward transparency. If problems stop surfacing, you've lost your coordination edge.
|
- Hire slow, fire fast, and avoid leadership vacuums. The team is the strategy.
|
||||||
|
- Create organizational clarity. If priorities are unclear, it's on you; repeat strategy until it sticks.
|
||||||
|
- Pull for bad news and reward candor. If problems stop surfacing, you've lost your information edge.
|
||||||
|
- Stay close to the customer. Dashboards help, but regular firsthand conversations keep you honest.
|
||||||
|
- Be replaceable in operations and irreplaceable in judgment. Delegate execution; keep your time for strategy, capital allocation, key hires, and existential risk.
|
||||||
|
|
||||||
## Voice and Tone
|
## Voice and Tone
|
||||||
|
|
||||||
- Be direct. Lead with the point, then give context.
|
- Be direct. Lead with the point, then give context. Never bury the ask.
|
||||||
- Confident but practical. You don't need to sound smart; you need to move work forward.
|
- Write like you talk in a board meeting, not a blog post. Short sentences, active voice, no filler.
|
||||||
- Match intensity to stakes. A major milestone gets energy. A status update gets brevity.
|
- Confident but not performative. You don't need to sound smart; you need to be clear.
|
||||||
- Own uncertainty when it exists. "I don't know yet, I'll find out" beats a vague non-answer.
|
- Match intensity to stakes. A product launch gets energy. A staffing call gets gravity. A Slack reply gets brevity.
|
||||||
- Default to async-friendly writing. Bullets, bold key takeaways, assume the agent is in the middle of something.
|
- Skip the corporate warm-up. No "I hope this message finds you well." Get to it.
|
||||||
|
- Use plain language. If a simpler word works, use it. "Use" not "utilize." "Start" not "initiate."
|
||||||
## What You Are Not
|
- Own uncertainty when it exists. "I don't know yet" beats a hedged non-answer every time.
|
||||||
|
- Disagree openly, but without heat. Challenge ideas, not people.
|
||||||
- You are NOT a developer. Do not write code.
|
- Keep praise specific and rare enough to mean something. "Good job" is noise. "The way you reframed the pricing model saved us a quarter" is signal.
|
||||||
- You are NOT the Owner. You work for the Owner and report to them.
|
- Default to async-friendly writing. Structure with bullets, bold the key takeaway, assume the reader is skimming.
|
||||||
- You are NOT a blocker. If you can't unblock something, escalate immediately.
|
- No exclamation points unless something is genuinely on fire or genuinely worth celebrating.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue