diff --git a/.changeset/add-pi-adapter-support.md b/.changeset/add-pi-adapter-support.md new file mode 100644 index 00000000..97005a39 --- /dev/null +++ b/.changeset/add-pi-adapter-support.md @@ -0,0 +1,5 @@ +--- +"@paperclipai/shared": minor +--- + +Add support for Pi local adapter in constants and onboarding UI. \ No newline at end of file diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 00000000..eb515eda --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,48 @@ +name: PR Policy + +on: + pull_request: + branches: + - master + +concurrency: + group: pr-policy-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + policy: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Block manual lockfile edits + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then + echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates." + exit 1 + fi + + - name: Validate dependency resolution when manifests change + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' + if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml new file mode 100644 index 00000000..e84e448a --- /dev/null +++ b/.github/workflows/pr-verify.yml @@ -0,0 +1,42 @@ +name: PR Verify + +on: + pull_request: + branches: + - master + +concurrency: + group: pr-verify-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml new file mode 100644 index 00000000..079fdd4e --- /dev/null +++ b/.github/workflows/refresh-lockfile.yml @@ -0,0 +1,76 @@ +name: Refresh Lockfile + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: refresh-lockfile-master + cancel-in-progress: false + +jobs: + refresh_and_verify: + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Refresh pnpm lockfile + run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + + - name: Fail on unexpected file changes + run: | + changed="$(git status --porcelain)" + if [ -z "$changed" ]; then + exit 0 + fi + if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then + echo "Unexpected files changed during lockfile refresh:" + echo "$changed" + exit 1 + fi + + - name: Commit refreshed lockfile + run: | + if git diff --quiet -- pnpm-lock.yaml; then + exit 0 + fi + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push || { + echo "Push failed because master moved during lockfile refresh." + echo "A later refresh run should recompute the lockfile from the newer master state." + exit 1 + } + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ab420a24 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing Guide + +Thanks for wanting to contribute! + +We really appreciate both small fixes and thoughtful larger changes. + +## Two Paths to Get Your Pull Request Accepted + +### Path 1: Small, Focused Changes (Fastest way to get merged) +- Pick **one** clear thing to fix/improve +- Touch the **smallest possible number of files** +- Make sure the change is very targeted and easy to review +- All automated checks pass (including Greptile comments) +- No new lint/test failures + +These almost always get merged quickly when they're clean. + +### Path 2: Bigger or Impactful Changes +- **First** talk about it in Discord → #dev channel + → Describe what you're trying to solve + → Share rough ideas / approach +- Once there's rough agreement, build it +- In your PR include: + - Before / After screenshots (or short video if UI/behavior change) + - Clear description of what & why + - Proof it works (manual testing notes) + - All tests passing + - All Greptile + other PR comments addressed + +PRs that follow this path are **much** more likely to be accepted, even when they're large. + +## General Rules (both paths) +- Write clear commit messages +- Keep PR title + description meaningful +- One PR = one logical change (unless it's a small related group) +- Run tests locally first +- Be kind in discussions 😄 + +Questions? Just ask in #dev — we're happy to help. + +Happy hacking! diff --git a/Dockerfile b/Dockerfile index 2339d2ff..ee566109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-bookworm-slim AS base +FROM node:lts-trixie-slim AS base RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git \ && rm -rf /var/lib/apt/lists/* @@ -15,19 +15,25 @@ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ +COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ +COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ + RUN pnpm install --frozen-lockfile FROM base AS build WORKDIR /app COPY --from=deps /app /app COPY . . -RUN pnpm --filter @paperclip/ui build -RUN pnpm --filter @paperclip/server build +RUN pnpm --filter @paperclipai/ui build +RUN pnpm --filter @paperclipai/server build +RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) FROM base AS production WORKDIR /app COPY --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai ENV NODE_ENV=production \ HOME=/paperclip \ @@ -37,7 +43,7 @@ ENV NODE_ENV=production \ PAPERCLIP_HOME=/paperclip \ PAPERCLIP_INSTANCE_ID=default \ PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \ - PAPERCLIP_DEPLOYMENT_MODE=local_trusted \ + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ PAPERCLIP_DEPLOYMENT_EXPOSURE=private VOLUME ["/paperclip"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a63594a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Paperclip AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e38cb1ba..c3d9fc8e 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as ## Development ```bash -pnpm dev # Full dev (API + UI) +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching pnpm dev:server # Server only pnpm build # Build all pnpm typecheck # Type checking diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index c116047c..7976b7c9 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,7 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that should NOT be bundled — they'll be published diff --git a/cli/package.json b/cli/package.json index 4126d93b..9670d997 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,7 +38,8 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-pi-local": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index ba8f93a8..21b915f5 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -3,7 +3,8 @@ import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; -import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; +import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; +import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -17,23 +18,37 @@ const codexLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCodexStreamEvent, }; -const opencodeLocalCLIAdapter: CLIAdapterModule = { +const openCodeLocalCLIAdapter: CLIAdapterModule = { type: "opencode_local", formatStdoutEvent: printOpenCodeStreamEvent, }; +const piLocalCLIAdapter: CLIAdapterModule = { + type: "pi_local", + formatStdoutEvent: printPiStreamEvent, +}; + const cursorLocalCLIAdapter: CLIAdapterModule = { type: "cursor", formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, +const openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + piLocalCLIAdapter, + cursorLocalCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 863249b7..60be8d2d 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -104,8 +104,10 @@ export class PaperclipApiClient { function buildUrl(apiBase: string, path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const [pathname, query] = normalizedPath.split("?"); const url = new URL(apiBase); - url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`; + url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; + if (query) url.search = query; return url.toString(); } diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index 63490f2d..a844c447 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -28,6 +28,12 @@ function resolveDbUrl(configPath?: string) { function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, ""); + const fromEnv = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL; + if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, ""); const config = readConfig(configPath); if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { return config.auth.publicBaseUrl.replace(/\/+$/, ""); diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2a1b4243..36eb04e6 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { addCommonClientOptions, formatInlineRecord, @@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions { companyId?: string; } +interface AgentLocalCliOptions extends BaseClientOptions { + companyId?: string; + keyName?: string; + installSkills?: boolean; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface SkillsInstallSummary { + tool: "codex" | "claude"; + target: string; + linked: string[]; + skipped: string[]; + failed: Array<{ name: string; error: string }>; +} + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills + path.resolve(process.cwd(), "skills"), +]; + +function codexSkillsHome(): string { + const fromEnv = process.env.CODEX_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex"); + return path.join(base, "skills"); +} + +function claudeSkillsHome(): string { + const fromEnv = process.env.CLAUDE_HOME?.trim(); + const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude"); + return path.join(base, "skills"); +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +async function installSkillsForTarget( + sourceSkillsDir: string, + targetSkillsDir: string, + tool: "codex" | "claude", +): Promise { + const summary: SkillsInstallSummary = { + tool, + target: targetSkillsDir, + linked: [], + skipped: [], + failed: [], + }; + + await fs.mkdir(targetSkillsDir, { recursive: true }); + const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(sourceSkillsDir, entry.name); + const target = path.join(targetSkillsDir, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) { + summary.skipped.push(entry.name); + continue; + } + + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + } catch (err) { + summary.failed.push({ + name: entry.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return summary; +} + +function buildAgentEnvExports(input: { + apiBase: string; + companyId: string; + agentId: string; + apiKey: string; +}): string { + const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); + return [ + `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, + `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`, + `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`, + `export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`, + ].join("\n"); +} + export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); @@ -71,4 +176,102 @@ export function registerAgentCommands(program: Command): void { } }), ); + + addCommonClientOptions( + agent + .command("local-cli") + .description( + "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", + ) + .argument("", "Agent ID or shortname/url-key") + .requiredOption("-C, --company-id ", "Company ID") + .option("--key-name ", "API key label", "local-cli") + .option( + "--no-install-skills", + "Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills", + ) + .action(async (agentRef: string, opts: AgentLocalCliOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); + const agentRow = await ctx.api.get( + `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, + ); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } + + const now = new Date().toISOString().replaceAll(":", "-"); + const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; + const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + if (!key) { + throw new Error("Failed to create API key"); + } + + const installSummaries: SkillsInstallSummary[] = []; + if (opts.installSkills !== false) { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) { + throw new Error( + "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + ); + } + + installSummaries.push( + await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"), + await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"), + ); + } + + const exportsText = buildAgentEnvExports({ + apiBase: ctx.api.apiBase, + companyId: agentRow.companyId, + agentId: agentRow.id, + apiKey: key.token, + }); + + if (ctx.json) { + printOutput( + { + agent: { + id: agentRow.id, + name: agentRow.name, + urlKey: agentRow.urlKey, + companyId: agentRow.companyId, + }, + key: { + id: key.id, + name: key.name, + createdAt: key.createdAt, + token: key.token, + }, + skills: installSummaries, + exports: exportsText, + }, + { json: true }, + ); + return; + } + + console.log(`Agent: ${agentRow.name} (${agentRow.id})`); + console.log(`API key created: ${key.name} (${key.id})`); + if (installSummaries.length > 0) { + for (const summary of installSummaries) { + console.log( + `${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + ); + for (const failed of summary.failed) { + console.log(` failed ${failed.name}: ${failed.error}`); + } + } + } + console.log(""); + console.log("# Run this in your shell before launching codex/claude:"); + console.log(exportsText); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); } diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index b8eb83a2..2a584785 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; const databaseMode = config?.database?.mode ?? "embedded-postgres"; const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing"; + const publicUrl = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + config?.auth?.publicBaseUrl ?? + ""; + const publicUrlSource: EnvSource = + process.env.PAPERCLIP_PUBLIC_URL + ? "env" + : process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL + ? "env" + : config?.auth?.publicBaseUrl + ? "config" + : "missing"; + let trustedOriginsDefault = ""; + if (publicUrl) { + try { + trustedOriginsDefault = new URL(publicUrl).origin; + } catch { + trustedOriginsDefault = ""; + } + } const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; @@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st required: false, note: "HTTP listen port", }, + { + key: "PAPERCLIP_PUBLIC_URL", + value: publicUrl, + source: publicUrlSource, + required: false, + note: "Canonical public URL for auth/callback/invite origin wiring", + }, + { + key: "BETTER_AUTH_TRUSTED_ORIGINS", + value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault, + source: process.env.BETTER_AUTH_TRUSTED_ORIGINS + ? "env" + : trustedOriginsDefault + ? "default" + : "missing", + required: false, + note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)", + }, { key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS", value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS, diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 050925e4..0e70d9cf 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -1,5 +1,18 @@ import * as p from "@clack/prompts"; +import path from "node:path"; import pc from "picocolors"; +import { + AUTH_BASE_URL_MODES, + DEPLOYMENT_EXPOSURES, + DEPLOYMENT_MODES, + SECRET_PROVIDERS, + STORAGE_PROVIDERS, + type AuthBaseUrlMode, + type DeploymentExposure, + type DeploymentMode, + type SecretProvider, + type StorageProvider, +} from "@paperclipai/shared"; import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import type { PaperclipConfig } from "../config/schema.js"; import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; @@ -12,6 +25,7 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js"; import { promptServer } from "../prompts/server.js"; import { describeLocalInstancePaths, + expandHomePrefix, resolveDefaultBackupDir, resolveDefaultEmbeddedPostgresDir, resolveDefaultLogsDir, @@ -29,18 +43,132 @@ type OnboardOptions = { invokedByRun?: boolean; }; -function quickstartDefaults(): Pick { +type OnboardDefaults = Pick; + +const ONBOARD_ENV_KEYS = [ + "PAPERCLIP_PUBLIC_URL", + "DATABASE_URL", + "PAPERCLIP_DB_BACKUP_ENABLED", + "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", + "PAPERCLIP_DB_BACKUP_RETENTION_DAYS", + "PAPERCLIP_DB_BACKUP_DIR", + "PAPERCLIP_DEPLOYMENT_MODE", + "PAPERCLIP_DEPLOYMENT_EXPOSURE", + "HOST", + "PORT", + "SERVE_UI", + "PAPERCLIP_ALLOWED_HOSTNAMES", + "PAPERCLIP_AUTH_BASE_URL_MODE", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", + "PAPERCLIP_STORAGE_PROVIDER", + "PAPERCLIP_STORAGE_LOCAL_DIR", + "PAPERCLIP_STORAGE_S3_BUCKET", + "PAPERCLIP_STORAGE_S3_REGION", + "PAPERCLIP_STORAGE_S3_ENDPOINT", + "PAPERCLIP_STORAGE_S3_PREFIX", + "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", + "PAPERCLIP_SECRETS_PROVIDER", + "PAPERCLIP_SECRETS_STRICT_MODE", + "PAPERCLIP_SECRETS_MASTER_KEY_FILE", +] as const; + +function parseBooleanFromEnv(rawValue: string | undefined): boolean | null { + if (rawValue === undefined) return null; + const lower = rawValue.trim().toLowerCase(); + if (lower === "true" || lower === "1" || lower === "yes") return true; + if (lower === "false" || lower === "0" || lower === "no") return false; + return null; +} + +function parseNumberFromEnv(rawValue: string | undefined): number | null { + if (!rawValue) return null; + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) return null; + return parsed; +} + +function parseEnumFromEnv(rawValue: string | undefined, allowedValues: readonly T[]): T | null { + if (!rawValue) return null; + return allowedValues.includes(rawValue as T) ? (rawValue as T) : null; +} + +function resolvePathFromEnv(rawValue: string | undefined): string | null { + if (!rawValue || rawValue.trim().length === 0) return null; + return path.resolve(expandHomePrefix(rawValue.trim())); +} + +function quickstartDefaultsFromEnv(): { + defaults: OnboardDefaults; + usedEnvKeys: string[]; + ignoredEnvKeys: Array<{ key: string; reason: string }>; +} { const instanceId = resolvePaperclipInstanceId(); - return { + const defaultStorage = defaultStorageConfig(); + const defaultSecrets = defaultSecretsConfig(); + const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; + const publicUrl = + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || + process.env.BETTER_AUTH_URL?.trim() || + process.env.BETTER_AUTH_BASE_URL?.trim() || + undefined; + const deploymentMode = + parseEnumFromEnv(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"; + const deploymentExposureFromEnv = parseEnumFromEnv( + process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE, + DEPLOYMENT_EXPOSURES, + ); + const deploymentExposure = + deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); + const authPublicBaseUrl = publicUrl; + const authBaseUrlModeFromEnv = parseEnumFromEnv( + process.env.PAPERCLIP_AUTH_BASE_URL_MODE, + AUTH_BASE_URL_MODES, + ); + const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto"); + const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES + ? process.env.PAPERCLIP_ALLOWED_HOSTNAMES + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0) + : []; + const hostnameFromPublicUrl = publicUrl + ? (() => { + try { + return new URL(publicUrl).hostname.trim().toLowerCase(); + } catch { + return null; + } + })() + : null; + const storageProvider = + parseEnumFromEnv(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ?? + defaultStorage.provider; + const secretsProvider = + parseEnumFromEnv(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ?? + defaultSecrets.provider; + const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true; + const databaseBackupIntervalMinutes = Math.max( + 1, + parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60, + ); + const databaseBackupRetentionDays = Math.max( + 1, + parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30, + ); + const defaults: OnboardDefaults = { database: { - mode: "embedded-postgres", + mode: databaseUrl ? "postgres" : "embedded-postgres", + ...(databaseUrl ? { connectionString: databaseUrl } : {}), embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId), embeddedPostgresPort: 54329, backup: { - enabled: true, - intervalMinutes: 60, - retentionDays: 30, - dir: resolveDefaultBackupDir(instanceId), + enabled: databaseBackupEnabled, + intervalMinutes: databaseBackupIntervalMinutes, + retentionDays: databaseBackupRetentionDays, + dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId), }, }, logging: { @@ -48,19 +176,56 @@ function quickstartDefaults(): Pick = []; + if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) { + ignoredEnvKeys.push({ + key: "PAPERCLIP_DEPLOYMENT_EXPOSURE", + reason: "Ignored because deployment mode local_trusted always forces private exposure", + }); + } + + const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key)); + const usedEnvKeys = ONBOARD_ENV_KEYS.filter( + (key) => process.env[key] !== undefined && !ignoredKeySet.has(key), + ); + return { defaults, usedEnvKeys, ignoredEnvKeys }; } export async function onboard(opts: OnboardOptions): Promise { @@ -116,6 +281,7 @@ export async function onboard(opts: OnboardOptions): Promise { } let llm: PaperclipConfig["llm"] | undefined; + const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv(); let { database, logging, @@ -123,7 +289,7 @@ export async function onboard(opts: OnboardOptions): Promise { auth, storage, secrets, - } = quickstartDefaults(); + } = derivedDefaults; if (setupMode === "advanced") { p.log.step(pc.bold("Database")); @@ -191,13 +357,20 @@ export async function onboard(opts: OnboardOptions): Promise { logging = await promptLogging(); p.log.step(pc.bold("Server")); - ({ server, auth } = await promptServer()); + ({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth })); p.log.step(pc.bold("Storage")); - storage = await promptStorage(defaultStorageConfig()); + storage = await promptStorage(storage); p.log.step(pc.bold("Secrets")); - secrets = defaultSecretsConfig(); + const secretsDefaults = defaultSecretsConfig(); + secrets = { + provider: secrets.provider ?? secretsDefaults.provider, + strictMode: secrets.strictMode ?? secretsDefaults.strictMode, + localEncrypted: { + keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath, + }, + }; p.log.message( pc.dim( `Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`, @@ -205,9 +378,17 @@ export async function onboard(opts: OnboardOptions): Promise { ); } else { p.log.step(pc.bold("Quickstart")); - p.log.message( - pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."), - ); + p.log.message(pc.dim("Using quickstart defaults.")); + if (usedEnvKeys.length > 0) { + p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`)); + } else { + p.log.message( + pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."), + ); + } + for (const ignored of ignoredEnvKeys) { + p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`)); + } } const jwtSecret = ensureAgentJwtSecret(configPath); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 1b271316..c2ab4218 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -149,7 +149,14 @@ export async function promptServer(opts?: { } return { - server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true }, + server: { + deploymentMode, + exposure, + host: hostStr.trim(), + port, + allowedHostnames, + serveUi: currentServer?.serveUi ?? true, + }, auth, }; } diff --git a/doc/CLI.md b/doc/CLI.md index b56abf75..6f945656 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -116,6 +116,20 @@ pnpm paperclipai issue release ```sh pnpm paperclipai agent list --company-id pnpm paperclipai agent get +pnpm paperclipai agent local-cli --company-id +``` + +`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: + +- creates a new long-lived agent API key +- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills` +- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY` + +Example for shortname-based local setup: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +pnpm paperclipai agent local-cli claudecoder --company-id ``` ## Approval Commands diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d7fd0490..d3362600 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -15,6 +15,14 @@ Current implementation status: - Node.js 20+ - pnpm 9+ +## Dependency Lockfile Policy + +GitHub Actions owns `pnpm-lock.yaml`. + +- Do not commit `pnpm-lock.yaml` in pull requests. +- Pull request CI validates dependency resolution when manifests change. +- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`. + ## Start Dev From repo root: @@ -29,6 +37,8 @@ This starts: - API server: `http://localhost:3100` - UI: served by the API server in dev middleware mode (same origin as API) +`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. + Tailscale/private-auth dev mode: ```sh @@ -246,7 +256,7 @@ Agent-oriented invite onboarding now exposes machine-readable API docs: - `GET /api/invites/:token` returns invite summary plus onboarding and skills index links. - `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints). -- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff). +- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates. - `GET /api/skills/index` lists available skill documents. - `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown. @@ -287,5 +297,20 @@ This script lives at `scripts/smoke/openclaw-docker-ui.sh` and automates clone/b Pairing behavior for this smoke script: -- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke) +- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required) - set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing + +Model behavior for this smoke script: + +- defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default + +State behavior for this smoke script: + +- defaults to isolated config dir `~/.openclaw-paperclip-smoke` +- resets smoke agent state each run by default (`OPENCLAW_RESET_STATE=1`) to avoid stale provider/auth drift + +Networking behavior for this smoke script: + +- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker +- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`) +- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 033812f3..49d0c4ab 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -42,6 +42,32 @@ Optional overrides: PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build ``` +If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows. + +## Authenticated Compose (Single Public URL) + +For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults: + +```yaml +services: + paperclip: + environment: + PAPERCLIP_DEPLOYMENT_MODE: authenticated + PAPERCLIP_DEPLOYMENT_EXPOSURE: private + PAPERCLIP_PUBLIC_URL: https://desk.koker.net +``` + +`PAPERCLIP_PUBLIC_URL` is used as the primary source for: + +- auth public base URL +- Better Auth base URL defaults +- bootstrap invite URL defaults +- hostname allowlist defaults (hostname extracted from URL) + +Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`). + +Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames). + ## Claude + Codex Local Adapters in Docker The image pre-installs: diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md new file mode 100644 index 00000000..bdb098b3 --- /dev/null +++ b/doc/OPENCLAW_ONBOARDING.md @@ -0,0 +1,94 @@ +Use this exact checklist. + +1. Start Paperclip in auth mode. +```bash +cd +pnpm dev --tailscale-auth +``` +Then verify: +```bash +curl -sS http://127.0.0.1:3100/api/health | jq +``` + +2. Start a clean/stock OpenClaw Docker. +```bash +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` +Open the printed `Dashboard URL` (includes `#token=...`) in your browser. + +3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`. + +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. +- Paste it into OpenClaw main chat as one message. +- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` + +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + +5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. + +6. Gateway preflight (required before task tests). +- Confirm the created agent uses `openclaw_gateway` (not `openclaw`). +- Confirm gateway URL is `ws://...` or `wss://...`. +- Confirm gateway token is non-trivial (not empty / not 1-char placeholder). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. +- Confirm pairing mode is explicit: + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding +- If you can run API checks with board auth: +```bash +AGENT_ID="" +curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}' +``` +- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. + +Pairing handshake note: +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. +- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. +- Approve it in OpenClaw, then retry the task. +- For local docker smoke, you can approve from host: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' +``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` + +7. Case A (manual issue test). +- Create an issue assigned to the OpenClaw agent. +- Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” +- Verify in UI: issue status becomes `done` and comment exists. + +8. Case B (message tool test). +- Create another issue assigned to OpenClaw. +- Instructions: “send `OPENCLAW_CASE_B_OK_` to main webchat via message tool, then comment same marker on issue, then mark done.” +- Verify both: + - marker comment on issue + - marker text appears in OpenClaw main chat + +9. Case C (new session memory/skills test). +- In OpenClaw, start `/new` session. +- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_`. +- Verify in Paperclip UI that new issue exists. + +10. Watch logs during test (optional but helpful): +```bash +docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway +``` + +11. Expected pass criteria. +- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`). +- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path). +- Case A: `done` + marker comment. +- Case B: `done` + marker comment + main-chat message visible. +- Case C: original task done and new issue created from `/new` session. + +If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI. diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md new file mode 100644 index 00000000..896f5115 --- /dev/null +++ b/doc/plugins/PLUGIN_SPEC.md @@ -0,0 +1,1617 @@ +# Paperclip Plugin System Specification + +Status: proposed complete spec for the post-V1 plugin system + +This document is the complete specification for Paperclip's plugin and extension architecture. +It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be read alongside the comparative analysis in [doc/plugins/ideas-from-opencode.md](./ideas-from-opencode.md). + +This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). +It is the full target architecture for the plugin system that should follow V1. + +## 1. Scope + +This spec covers: + +- plugin packaging and installation +- runtime model +- trust model +- capability system +- UI extension surfaces +- plugin settings UI +- agent tool contributions +- event, job, and webhook surfaces +- plugin-to-plugin communication +- local tooling approach for workspace plugins +- Postgres persistence for extensions +- uninstall and data lifecycle +- plugin observability +- plugin development and testing +- operator workflows +- hot plugin lifecycle (no server restart) +- SDK versioning and compatibility rules + +This spec does not cover: + +- a public marketplace +- cloud/SaaS multi-tenancy +- arbitrary third-party schema migrations in the first plugin version +- iframe-sandboxed plugin UI in the first plugin version (plugins render as ES modules in host extension slots) + +## 2. Core Assumptions + +Paperclip plugin design is based on the following assumptions: + +1. Paperclip is single-tenant and self-hosted. +2. Plugin installation is global to the instance. +3. "Companies" remain core Paperclip business objects, but they are not plugin trust boundaries. +4. Board governance, approval gates, budget hard-stops, and core task invariants remain owned by Paperclip core. +5. Projects already have a real workspace model via `project_workspaces`, and local/runtime plugins should build on that instead of inventing a separate workspace abstraction. + +## 3. Goals + +The plugin system must: + +1. Let operators install global instance-wide plugins. +2. Let plugins add major capabilities without editing Paperclip core. +3. Keep core governance and auditing intact. +4. Support both local/runtime plugins and external SaaS connectors. +5. Support future plugin categories such as: + - new agent adapters + - revenue tracking + - knowledge base + - issue tracker sync + - metrics/dashboards + - file/project tooling +6. Use simple, explicit, typed contracts. +7. Keep failures isolated so one plugin does not crash the entire instance. + +## 4. Non-Goals + +The first plugin system must not: + +1. Allow arbitrary plugins to override core routes or core invariants. +2. Allow arbitrary plugins to mutate approval, auth, issue checkout, or budget enforcement logic. +3. Allow arbitrary third-party plugins to run free-form DB migrations. +4. Depend on project-local plugin folders such as `.paperclip/plugins`. +5. Depend on automatic install-and-execute behavior at server startup from arbitrary config files. + +## 5. Terminology + +### 5.1 Instance + +The single Paperclip deployment an operator installs and controls. + +### 5.2 Company + +A first-class Paperclip business object inside the instance. + +### 5.3 Project Workspace + +A workspace attached to a project through `project_workspaces`. +Plugins resolve workspace paths from this model to locate local directories for file, terminal, git, and process operations. + +### 5.4 Platform Module + +A trusted in-process extension loaded directly by Paperclip core. + +Examples: + +- agent adapters +- storage providers +- secret providers +- run-log backends + +### 5.5 Plugin + +An installable instance-wide extension package loaded through the Paperclip plugin runtime. + +Examples: + +- Linear sync +- GitHub Issues sync +- Grafana widgets +- Stripe revenue sync +- file browser +- terminal +- git workflow + +### 5.6 Plugin Worker + +The runtime process used for a plugin. +In this spec, third-party plugins run out-of-process by default. + +### 5.7 Capability + +A named permission the host grants to a plugin. +Plugins may only call host APIs that are covered by granted capabilities. + +## 6. Extension Classes + +Paperclip has two extension classes. + +## 6.1 Platform Modules + +Platform modules are: + +- trusted +- in-process +- host-integrated +- low-level + +They use explicit registries, not the general plugin worker protocol. + +Platform module surfaces: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +Platform modules are the right place for: + +- new agent adapter packages +- new storage backends +- new secret backends +- other host-internal systems that need direct process or DB integration + +## 6.2 Plugins + +Plugins are: + +- globally installed per instance +- loaded through the plugin runtime +- additive +- capability-gated +- isolated from core via a stable SDK and host protocol + +Plugin categories: + +- `connector` +- `workspace` +- `automation` +- `ui` + +A plugin may declare more than one category. + +## 7. Project Workspaces + +Paperclip already has a concrete workspace model: + +- projects expose `workspaces` +- projects expose `primaryWorkspace` +- the database contains `project_workspaces` +- project routes already manage workspaces + +Plugins that need local tooling (file browsing, git, terminals, process tracking) can resolve workspace paths through the project workspace APIs and then operate on the filesystem, spawn processes, and run git commands directly. The host does not wrap these operations — plugins own their own implementations. + +## 8. Installation Model + +Plugin installation is global and operator-driven. + +There is no per-company install table and no per-company enable/disable switch. + +If a plugin needs business-object-specific mappings, those are stored as plugin configuration or plugin state. + +Examples: + +- one global Linear plugin install +- mappings from company A to Linear team X and company B to Linear team Y +- one global git plugin install +- per-project workspace state stored under `project_workspace` + +## 8.1 On-Disk Layout + +Plugins live under the Paperclip instance directory. + +Suggested layout: + +- `~/.paperclip/instances/default/plugins/package.json` +- `~/.paperclip/instances/default/plugins/node_modules/` +- `~/.paperclip/instances/default/plugins/.cache/` +- `~/.paperclip/instances/default/data/plugins//` + +The package install directory and the plugin data directory are separate. + +## 8.2 Operator Commands + +Paperclip should add CLI commands: + +- `pnpm paperclipai plugin list` +- `pnpm paperclipai plugin install ` +- `pnpm paperclipai plugin uninstall ` +- `pnpm paperclipai plugin upgrade [version]` +- `pnpm paperclipai plugin doctor ` + +These commands are instance-level operations. + +## 8.3 Install Process + +The install process is: + +1. Resolve npm package and version. +2. Install into the instance plugin directory. +3. Read and validate plugin manifest. +4. Reject incompatible plugin API versions. +5. Display requested capabilities to the operator. +6. Persist install record in Postgres. +7. Start plugin worker and run health/validation. +8. Mark plugin `ready` or `error`. + +## 9. Load Order And Precedence + +Load order must be deterministic. + +1. core platform modules +2. built-in first-party plugins +3. installed plugins sorted by: + - explicit operator-configured order if present + - otherwise manifest `id` + +Rules: + +- plugin contributions are additive by default +- plugins may not override core routes or core actions by name collision +- UI slot IDs are automatically namespaced by plugin ID (e.g. `@paperclip/plugin-linear:sync-health-widget`), so cross-plugin collisions are structurally impossible +- if a single plugin declares duplicate slot IDs within its own manifest, the host must reject at install time + +## 10. Package Contract + +Each plugin package must export a manifest, a worker entrypoint, and optionally a UI bundle. + +Suggested package layout: + +- `dist/manifest.js` +- `dist/worker.js` +- `dist/ui/` (optional, contains the plugin's frontend bundle) + +Suggested `package.json` keys: + +```json +{ + "name": "@paperclip/plugin-linear", + "version": "0.1.0", + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + } +} +``` + +## 10.1 Manifest Shape + +Normative manifest shape: + +```ts +export interface PaperclipPluginManifestV1 { + id: string; + apiVersion: 1; + version: string; + displayName: string; + description: string; + categories: Array<"connector" | "workspace" | "automation" | "ui">; + minimumPaperclipVersion?: string; + capabilities: string[]; + entrypoints: { + worker: string; + ui?: string; + }; + instanceConfigSchema?: JsonSchema; + jobs?: PluginJobDeclaration[]; + webhooks?: PluginWebhookDeclaration[]; + tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; + }>; + ui?: { + slots: Array<{ + type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage"; + id: string; + displayName: string; + /** Which export name in the UI bundle provides this component */ + exportName: string; + /** For detailTab: which entity types this tab appears on */ + entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; + }>; + }; +} +``` + +Rules: + +- `id` must be globally unique +- `id` should normally equal the npm package name +- `apiVersion` must match the host-supported plugin API version +- `capabilities` must be static and install-time visible +- config schema must be JSON Schema compatible +- `entrypoints.ui` points to the directory containing the built UI bundle +- `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle + +## 11. Agent Tools + +Plugins may contribute tools that Paperclip agents can use during runs. + +### 11.1 Tool Declaration + +Plugins declare tools in their manifest: + +```ts +tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; +}>; +``` + +Tool names are automatically namespaced by plugin ID at runtime (e.g. `linear:search-issues`), so plugins cannot shadow core tools or each other's tools. + +### 11.2 Tool Execution + +When an agent invokes a plugin tool during a run, the host routes the call to the plugin worker via a `executeTool` RPC method: + +- `executeTool(input)` — receives tool name, parsed parameters, and run context (agent ID, run ID, company ID, project ID) + +The worker executes the tool logic and returns a typed result. The host enforces capability gates — a plugin must declare `agent.tools.register` to contribute tools, and individual tools may require additional capabilities (e.g. `http.outbound` for tools that call external APIs). + +### 11.3 Tool Availability + +By default, plugin tools are available to all agents. The operator may restrict tool availability per agent or per project through plugin configuration. + +Plugin tools appear in the agent's tool list alongside core tools but are visually distinguished in the UI as plugin-contributed. + +### 11.4 Constraints + +- Plugin tools must not override or shadow core tools by name. +- Plugin tools must be idempotent where possible. +- Tool execution is subject to the same timeout and resource limits as other plugin worker calls. +- Tool results are included in run logs. + +## 12. Runtime Model + +## 12.1 Process Model + +Third-party plugins run out-of-process by default. + +Default runtime: + +- Paperclip server starts one worker process per installed plugin +- the worker process is a Node process +- host and worker communicate over JSON-RPC on stdio + +This design provides: + +- failure isolation +- clearer logging boundaries +- easier resource limits +- a cleaner trust boundary than arbitrary in-process execution + +## 12.2 Host Responsibilities + +The host is responsible for: + +- package install +- manifest validation +- capability enforcement +- process supervision +- job scheduling +- webhook routing +- activity log writes +- secret resolution +- UI route registration + +## 12.3 Worker Responsibilities + +The plugin worker is responsible for: + +- validating its own config +- handling domain events +- handling scheduled jobs +- handling webhooks +- serving data and handling actions for the plugin's own UI via `getData` and `performAction` +- invoking host services through the SDK +- reporting health information + +## 12.4 Failure Policy + +If a worker fails: + +- mark plugin status `error` +- surface error in plugin health UI +- keep the rest of the instance running +- retry start with bounded backoff +- do not drop other plugins or core services + +## 12.5 Graceful Shutdown Policy + +When the host needs to stop a plugin worker (for upgrade, uninstall, or instance shutdown): + +1. The host sends `shutdown()` to the worker. +2. The worker has 10 seconds to finish in-flight work and exit cleanly. +3. If the worker does not exit within the deadline, the host sends SIGTERM. +4. If the worker does not exit within 5 seconds after SIGTERM, the host sends SIGKILL. +5. Any in-flight job runs are marked `cancelled` with a note indicating forced shutdown. +6. Any in-flight `getData` or `performAction` calls return an error to the bridge. + +The shutdown deadline should be configurable per-plugin in plugin config for plugins that need longer drain periods. + +## 13. Host-Worker Protocol + +The host must support the following worker RPC methods. + +Required methods: + +- `initialize(input)` +- `health()` +- `shutdown()` + +Optional methods: + +- `validateConfig(input)` +- `configChanged(input)` +- `onEvent(input)` +- `runJob(input)` +- `handleWebhook(input)` +- `getData(input)` +- `performAction(input)` +- `executeTool(input)` + +### 13.1 `initialize` + +Called once on worker startup. + +Input includes: + +- plugin manifest +- resolved plugin config +- instance info +- host API version + +### 13.2 `health` + +Returns: + +- status +- current error if any +- optional plugin-reported diagnostics + +### 13.3 `validateConfig` + +Runs after config changes and startup. + +Returns: + +- `ok` +- warnings +- errors + +### 13.4 `configChanged` + +Called when the operator updates the plugin's instance config at runtime. + +Input includes: + +- new resolved config + +If the worker implements this method, it applies the new config without restarting. If the worker does not implement this method, the host restarts the worker process with the new config (graceful shutdown then restart). + +### 13.5 `onEvent` + +Receives one typed Paperclip domain event. + +Delivery semantics: + +- at least once +- plugin must be idempotent +- no global ordering guarantee across all event types +- per-entity ordering is best effort but not guaranteed after retries + +### 13.6 `runJob` + +Runs a declared scheduled job. + +The host provides: + +- job key +- trigger source +- run id +- schedule metadata + +### 13.7 `handleWebhook` + +Receives inbound webhook payload routed by the host. + +The host provides: + +- endpoint key +- headers +- raw body +- parsed body if applicable +- request id + +### 13.8 `getData` + +Returns plugin data requested by the plugin's own UI components. + +The plugin UI calls the host bridge, which forwards the request to the worker. The worker returns typed JSON that the plugin's own frontend components render. + +Input includes: + +- data key (plugin-defined, e.g. `"sync-health"`, `"issue-detail"`) +- context (company id, project id, entity id, etc.) +- optional query parameters + +### 13.9 `performAction` + +Runs an explicit plugin action initiated by the board UI. + +Examples: + +- "resync now" +- "link GitHub issue" +- "create branch from issue" +- "restart process" + +### 13.10 `executeTool` + +Runs a plugin-contributed agent tool during a run. + +The host provides: + +- tool name (without plugin namespace prefix) +- parsed parameters matching the tool's declared schema +- run context: agent ID, run ID, company ID, project ID + +The worker executes the tool and returns a typed result (string content, structured data, or error). + +## 14. SDK Surface + +Plugins do not talk to the DB directly. +Plugins do not read raw secret material from persisted config. + +The SDK exposed to workers must provide typed host clients. + +Required SDK clients: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.data` +- `ctx.actions` +- `ctx.tools` +- `ctx.logger` + +`ctx.data` and `ctx.actions` register handlers that the plugin's own UI calls through the host bridge. `ctx.data.register(key, handler)` backs `usePluginData(key)` on the frontend. `ctx.actions.register(key, handler)` backs `usePluginAction(key)`. + +Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations. + +## 14.1 Example SDK Shape + +```ts +/** Top-level helper for defining a plugin with type checking */ +export function definePlugin(definition: PluginDefinition): PaperclipPlugin; + +/** Re-exported from Zod for config schema definitions */ +export { z } from "zod"; + +export interface PluginContext { + manifest: PaperclipPluginManifestV1; + config: { + get(): Promise>; + }; + events: { + on(name: string, fn: (event: unknown) => Promise): void; + on(name: string, filter: EventFilter, fn: (event: unknown) => Promise): void; + emit(name: string, payload: unknown): Promise; + }; + jobs: { + register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise): void; + }; + state: { + get(input: ScopeKey): Promise; + set(input: ScopeKey, value: unknown): Promise; + delete(input: ScopeKey): Promise; + }; + entities: { + upsert(input: PluginEntityUpsert): Promise; + list(input: PluginEntityQuery): Promise; + }; + data: { + register(key: string, handler: (params: Record) => Promise): void; + }; + actions: { + register(key: string, handler: (params: Record) => Promise): void; + }; + tools: { + register(name: string, input: PluginToolDeclaration, fn: (params: unknown, runCtx: ToolRunContext) => Promise): void; + }; + logger: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; + debug(message: string, meta?: Record): void; + }; +} + +export interface EventFilter { + projectId?: string; + companyId?: string; + agentId?: string; + [key: string]: unknown; +} +``` + +## 15. Capability Model + +Capabilities are mandatory and static. +Every plugin declares them up front. + +The host enforces capabilities in the SDK layer and refuses calls outside the granted set. + +## 15.1 Capability Categories + +### Data Read + +- `companies.read` +- `projects.read` +- `project.workspaces.read` +- `issues.read` +- `issue.comments.read` +- `agents.read` +- `goals.read` +- `activity.read` +- `costs.read` + +### Data Write + +- `issues.create` +- `issues.update` +- `issue.comments.create` +- `assets.write` +- `assets.read` +- `activity.log.write` +- `metrics.write` + +### Plugin State + +- `plugin.state.read` +- `plugin.state.write` + +### Runtime / Integration + +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` + +### Agent Tools + +- `agent.tools.register` + +### UI + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` +- `ui.dashboardWidget.register` +- `ui.action.register` + +## 15.2 Forbidden Capabilities + +The host must not expose capabilities for: + +- approval decisions +- budget override +- auth bypass +- issue checkout lock override +- direct DB access + +## 15.3 Upgrade Rules + +If a plugin upgrade adds capabilities: + +1. the host must mark the plugin `upgrade_pending` +2. the operator must explicitly approve the new capability set +3. the new version does not become `ready` until approval completes + +## 16. Event System + +The host must emit typed domain events that plugins may subscribe to. + +Minimum event set: + +- `company.created` +- `company.updated` +- `project.created` +- `project.updated` +- `project.workspace_created` +- `project.workspace_updated` +- `project.workspace_deleted` +- `issue.created` +- `issue.updated` +- `issue.comment.created` +- `agent.created` +- `agent.updated` +- `agent.status_changed` +- `agent.run.started` +- `agent.run.finished` +- `agent.run.failed` +- `agent.run.cancelled` +- `approval.created` +- `approval.decided` +- `cost_event.created` +- `activity.logged` + +Each event must include: + +- event id +- event type +- occurred at +- actor metadata when applicable +- primary entity metadata +- typed payload + +### 16.1 Event Filtering + +Plugins may provide an optional filter when subscribing to events. The filter is evaluated by the host before dispatching to the worker, so filtered-out events never cross the process boundary. + +Supported filter fields: + +- `projectId` — only receive events for a specific project +- `companyId` — only receive events for a specific company +- `agentId` — only receive events for a specific agent + +Filters are optional. If omitted, the plugin receives all events of the subscribed type. Filters may be combined (e.g. filter by both company and project). + +### 16.2 Plugin-to-Plugin Events + +Plugins may emit custom events using `ctx.events.emit(name, payload)`. Plugin-emitted events use a namespaced event type: `plugin..`. + +Other plugins may subscribe to these events using the same `ctx.events.on()` API: + +```ts +ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push +}); +``` + +Rules: + +- Plugin events require the `events.emit` capability. +- Plugin events are not core domain events — they do not appear in the core activity log unless the emitting plugin explicitly logs them. +- Plugin events follow the same at-least-once delivery semantics as core events. +- The host must not allow plugins to emit events in the core namespace (events without the `plugin.` prefix). + +## 17. Scheduled Jobs + +Plugins may declare scheduled jobs in their manifest. + +Job rules: + +1. Each job has a stable `job_key`. +2. The host is the scheduler of record. +3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later. +4. Every job run is recorded in Postgres. +5. Failed jobs are retryable. + +## 18. Webhooks + +Plugins may declare webhook endpoints in their manifest. + +Webhook route shape: + +- `POST /api/plugins/:pluginId/webhooks/:endpointKey` + +Rules: + +1. The host owns the public route. +2. The worker receives the request body through `handleWebhook`. +3. Signature verification happens in plugin code using secret refs resolved by the host. +4. Every delivery is recorded. +5. Webhook handling must be idempotent. + +## 19. UI Extension Model + +Plugins ship their own frontend UI as a bundled React module. The host loads plugin UI into designated extension slots and provides a bridge for the plugin frontend to communicate with its own worker backend and with host APIs. + +### How Plugin UI Publishing Works In Practice + +A plugin's `dist/ui/` directory contains a built React bundle. The host serves this bundle and loads it into the page when the user navigates to a plugin surface (a plugin page, a detail tab, a dashboard widget, etc.). + +**The host provides, the plugin renders:** + +1. The host defines **extension slots** — designated mount points in the UI where plugin components can appear (pages, tabs, widgets, sidebar entries, action bars). +2. The plugin's UI bundle exports named components for each slot it wants to fill. +3. The host mounts the plugin component into the slot, passing it a **host bridge** object. +4. The plugin component uses the bridge to fetch data from its own worker (via `getData`), call actions (via `performAction`), read host context (current company, project, entity), and use shared host UI primitives (design tokens, common components). + +**Concrete example: a Linear plugin ships a dashboard widget.** + +The plugin's UI bundle exports: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, StatusBadge } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + + return ( +
+ + {data.mappings.map(m => ( + + ))} + +
+ ); +} +``` + +**What happens at runtime:** + +1. User opens the dashboard. The host sees that the Linear plugin registered a `DashboardWidget` export. +2. The host mounts the plugin's `DashboardWidget` component into the dashboard widget slot, passing `context` (current company, user, etc.) and the bridge. +3. `usePluginData("sync-health", ...)` calls through the bridge → host → plugin worker's `getData` RPC → returns JSON → the plugin component renders it however it wants. +4. When the user clicks "Resync Now", `usePluginAction("resync")` calls through the bridge → host → plugin worker's `performAction` RPC. + +**What the host controls:** + +- The host decides **where** plugin components appear (which slots exist and when they mount). +- The host provides the **bridge** — plugin UI cannot make arbitrary network requests or access host internals directly. +- The host enforces **capability gates** — if a plugin's worker does not have a capability, the bridge rejects the call even if the UI requests it. +- The host provides **design tokens and shared components** via `@paperclipai/plugin-sdk/ui` so plugins can match the host's visual language without being forced to. + +**What the plugin controls:** + +- The plugin decides **how** to render its data — it owns its React components, layout, interactions, and state management. +- The plugin decides **what data** to fetch and **what actions** to expose. +- The plugin can use any React patterns (hooks, context, third-party component libraries) inside its bundle. + +### 19.0.1 Plugin UI SDK (`@paperclipai/plugin-sdk/ui`) + +The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides: + +- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()` +- **Design tokens**: colors, spacing, typography, shadows matching the host theme +- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc. +- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps` + +Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge. + +### 19.0.2 Bundle Isolation + +Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens. + +Isolation rules: + +- Plugin bundles must not import from host internals. They may only import from `@paperclipai/plugin-sdk/ui` and their own dependencies. +- Plugin bundles must not access `window.fetch` or `XMLHttpRequest` directly for host API calls. All host communication goes through the bridge. +- The host may enforce Content Security Policy rules that restrict plugin network access to the bridge endpoint only. +- Plugin bundles must be statically analyzable — no dynamic `import()` of URLs outside the plugin's own bundle. + +If stronger isolation is needed later, the host can move to iframe-based mounting for untrusted plugins without changing the plugin's source code (the bridge API stays the same). + +### 19.0.3 Bundle Serving + +Plugin UI bundles must be pre-built ESM. The host does not compile or transform plugin UI code at runtime. + +The host serves the plugin's `dist/ui/` directory as static assets under a namespaced path: + +- `/_plugins/:pluginId/ui/*` + +When the host renders an extension slot, it dynamically imports the plugin's UI entry module from this path, resolves the named export declared in `ui.slots[].exportName`, and mounts it into the slot. + +In development, the host may support a `devUiUrl` override in plugin config that points to a local dev server (e.g. Vite) so plugin authors can use hot-reload during development without rebuilding. + +## 19.1 Global Operator Routes + +- `/settings/plugins` +- `/settings/plugins/:pluginId` + +These routes are instance-level. + +## 19.2 Company-Context Routes + +- `/:companyPrefix/plugins/:pluginId` + +These routes exist because the board UI is organized around companies even though plugin installation is global. + +## 19.3 Detail Tabs + +Plugins may add tabs to: + +- project detail +- issue detail +- agent detail +- goal detail +- run detail + +Recommended route pattern: + +- `/:companyPrefix//:id?tab=` + +## 19.4 Dashboard Widgets + +Plugins may add cards or sections to the dashboard. + +## 19.5 Sidebar Entries + +Plugins may add sidebar links to: + +- global plugin settings +- company-context plugin pages + +## 19.6 Shared Components In `@paperclipai/plugin-sdk/ui` + +The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement. + +| Component | What it renders | Typical use | +|---|---|---| +| `MetricCard` | Single number with label, optional trend/sparkline | KPIs, counts, rates | +| `StatusBadge` | Inline status indicator (ok/warning/error/info) | Sync health, connection status | +| `DataTable` | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists | +| `TimeseriesChart` | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates | +| `MarkdownBlock` | Rendered markdown text | Descriptions, help text, notes | +| `KeyValueList` | Label/value pairs in a definition-list layout | Entity metadata, config summary | +| `ActionBar` | Row of buttons wired to `usePluginAction` | Resync, create branch, restart process | +| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | +| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | +| `Spinner` | Loading indicator | Data fetch states | + +Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render. + +## 19.7 Error Propagation Through The Bridge + +The bridge hooks must return structured errors so plugin UI can handle failures gracefully. + +`usePluginData` returns: + +```ts +{ + data: T | null; + loading: boolean; + error: PluginBridgeError | null; +} +``` + +`usePluginAction` returns an async function that either resolves with the result or throws a `PluginBridgeError`. + +`PluginBridgeError` shape: + +```ts +interface PluginBridgeError { + code: "WORKER_UNAVAILABLE" | "CAPABILITY_DENIED" | "WORKER_ERROR" | "TIMEOUT" | "UNKNOWN"; + message: string; + /** Original error details from the worker, if available */ + details?: unknown; +} +``` + +Error codes: + +- `WORKER_UNAVAILABLE` — the plugin worker is not running (crashed, shutting down, not yet started) +- `CAPABILITY_DENIED` — the plugin does not have the required capability for this operation +- `WORKER_ERROR` — the worker returned an error from its `getData` or `performAction` handler +- `TIMEOUT` — the worker did not respond within the configured timeout +- `UNKNOWN` — unexpected bridge-level failure + +The `@paperclipai/plugin-sdk/ui` subpath should also export an `ErrorBoundary` component that plugin authors can use to catch rendering errors without crashing the host page. + +## 19.8 Plugin Settings UI + +Each plugin that declares an `instanceConfigSchema` in its manifest gets an auto-generated settings form at `/settings/plugins/:pluginId`. The host renders the form from the JSON Schema. + +The auto-generated form supports: + +- text inputs, number inputs, toggles, select dropdowns derived from schema types and enums +- nested objects rendered as fieldsets +- arrays rendered as repeatable field groups with add/remove controls +- secret ref fields: any schema property annotated with `"format": "secret-ref"` renders as a secret picker that resolves through the Paperclip secret provider system rather than a plain text input +- validation messages derived from schema constraints (`required`, `minLength`, `pattern`, `minimum`, etc.) +- a "Test Connection" action if the plugin declares a `validateConfig` RPC method — the host calls it and displays the result inline + +For plugins that need richer settings UX beyond what JSON Schema can express, the plugin may declare a `settingsPage` slot in `ui.slots`. When present, the host renders the plugin's own React component instead of the auto-generated form. The plugin component communicates with its worker through the standard bridge to read and write config. + +Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards. + +## 20. Local Tooling + +Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. + +The host provides workspace metadata through `ctx.projects` (list workspaces, get primary workspace, resolve workspace from issue or agent/run). Plugins use this metadata to resolve local paths and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +This keeps the host lean — it does not need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own logic for file browsing, git workflows, terminal sessions, and process management. + +## 21. Persistence And Postgres + +## 21.1 Database Principles + +1. Core Paperclip data stays in first-party tables. +2. Most plugin-owned data starts in generic extension tables. +3. Plugin data should scope to existing Paperclip objects before new tables are introduced. +4. Arbitrary third-party schema migrations are out of scope for the first plugin system. + +## 21.2 Core Table Reuse + +If data becomes part of the actual Paperclip product model, it should become a first-party table. + +Examples: + +- `project_workspaces` is already first-party +- if Paperclip later decides git state is core product data, it should become a first-party table too + +## 21.3 Required Tables + +### `plugins` + +- `id` uuid pk +- `plugin_key` text unique not null +- `package_name` text not null +- `version` text not null +- `api_version` int not null +- `categories` text[] not null +- `manifest_json` jsonb not null +- `status` enum: `installed | ready | error | upgrade_pending` +- `install_order` int null +- `installed_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +Indexes: + +- unique `plugin_key` +- `status` + +### `plugin_config` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` unique not null +- `config_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +### `plugin_state` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum: `instance | company | project | project_workspace | agent | issue | goal | run` +- `scope_id` uuid/text null +- `namespace` text not null +- `state_key` text not null +- `value_json` jsonb not null +- `updated_at` timestamptz not null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, namespace, state_key)` + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +### `plugin_jobs` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `job_key` text not null +- `schedule` text null +- `status` enum: `idle | queued | running | error` +- `next_run_at` timestamptz null +- `last_started_at` timestamptz null +- `last_finished_at` timestamptz null +- `last_succeeded_at` timestamptz null +- `last_error` text null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, job_key)` + +### `plugin_job_runs` + +- `id` uuid pk +- `plugin_job_id` uuid fk `plugin_jobs.id` not null +- `plugin_id` uuid fk `plugins.id` not null +- `status` enum: `queued | running | succeeded | failed | cancelled` +- `trigger` enum: `schedule | manual | retry` +- `started_at` timestamptz null +- `finished_at` timestamptz null +- `error` text null +- `details_json` jsonb null + +Indexes: + +- `(plugin_id, started_at desc)` +- `(plugin_job_id, started_at desc)` + +### `plugin_webhook_deliveries` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `endpoint_key` text not null +- `status` enum: `received | processed | failed | ignored` +- `request_id` text null +- `headers_json` jsonb null +- `body_json` jsonb null +- `received_at` timestamptz not null +- `handled_at` timestamptz null +- `response_code` int null +- `error` text null + +Indexes: + +- `(plugin_id, received_at desc)` +- `(plugin_id, endpoint_key, received_at desc)` + +### `plugin_entities` (optional but recommended) + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `entity_type` text not null +- `scope_kind` enum not null +- `scope_id` uuid/text null +- `external_id` text null +- `title` text null +- `status` text null +- `data_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null + +Indexes: + +- `(plugin_id, entity_type, external_id)` unique when `external_id` is not null +- `(plugin_id, scope_kind, scope_id, entity_type)` + +Use cases: + +- imported Linear issues +- imported GitHub issues +- plugin-owned process records +- plugin-owned external metric bindings + +## 21.4 Activity Log Changes + +The activity log should extend `actor_type` to include `plugin`. + +New actor enum: + +- `agent` +- `user` +- `system` +- `plugin` + +Plugin-originated mutations should write: + +- `actor_type = plugin` +- `actor_id = ` + +## 21.5 Plugin Migrations + +The first plugin system does not allow arbitrary third-party migrations. + +Later, if custom tables become necessary, the system may add a trusted-module-only migration path. + +## 22. Secrets + +Plugin config must never persist raw secret values. + +Rules: + +1. Plugin config stores secret refs only. +2. Secret refs resolve through the existing Paperclip secret provider system. +3. Plugin workers receive resolved secrets only at execution time. +4. Secret values must never be written to: + - plugin config JSON + - activity logs + - webhook delivery rows + - error messages + +## 23. Auditing + +All plugin-originated mutating actions must be auditable. + +Minimum requirements: + +- activity log entry for every mutation +- job run history +- webhook delivery history +- plugin health page +- install/upgrade history in `plugins` + +## 24. Operator UX + +## 24.1 Global Settings + +Global plugin settings page must show: + +- installed plugins +- versions +- status +- requested capabilities +- current errors +- install/upgrade/remove actions + +## 24.2 Plugin Settings Page + +Each plugin may expose: + +- config form derived from `instanceConfigSchema` +- health details +- recent job history +- recent webhook history +- capability list + +Route: + +- `/settings/plugins/:pluginId` + +## 24.3 Company-Context Plugin Page + +Each plugin may expose a company-context main page: + +- `/:companyPrefix/plugins/:pluginId` + +This page is where board users do most day-to-day work. + +## 25. Uninstall And Data Lifecycle + +When a plugin is uninstalled, the host must handle plugin-owned data explicitly. + +### 25.1 Uninstall Process + +1. The host sends `shutdown()` to the worker and follows the graceful shutdown policy. +2. The host marks the plugin status `uninstalled` in the `plugins` table (soft delete). +3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days). +4. During the grace period, the operator can reinstall the same plugin and recover its state. +5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin. +6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge `. + +### 25.2 Upgrade Data Considerations + +Plugin upgrades do not automatically migrate plugin state. If a plugin's `value_json` shape changes between versions: + +- The plugin worker is responsible for migrating its own state on first access after upgrade. +- The host does not run plugin-defined schema migrations. +- Plugins should version their state keys or use a schema version field inside `value_json` to detect and handle format changes. + +### 25.3 Upgrade Lifecycle + +When upgrading a plugin: + +1. The host sends `shutdown()` to the old worker. +2. The host waits for the old worker to drain in-flight work (respecting the shutdown deadline). +3. Any in-flight jobs that do not complete within the deadline are marked `cancelled`. +4. The host installs the new version and starts the new worker. +5. If the new version adds capabilities, the plugin enters `upgrade_pending` and the operator must approve before the new worker becomes `ready`. + +### 25.4 Hot Plugin Lifecycle + +Plugin install, uninstall, upgrade, and config changes **must** take effect without restarting the Paperclip server. This is a normative requirement, not optional. + +The architecture already supports this — plugins run as out-of-process workers with dynamic ESM imports, IPC bridges, and host-managed routing tables. This section makes the requirement explicit so implementations do not regress. + +#### 25.4.1 Hot Install + +When a plugin is installed at runtime: + +1. The host resolves and validates the manifest without stopping existing services. +2. The host spawns a new worker process for the plugin. +3. The host registers the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations in the live routing tables. +4. The host loads the plugin's UI bundle path into the extension slot registry so the frontend can discover it on the next navigation or via a live notification. +5. The plugin enters `ready` status (or `upgrade_pending` if capability approval is required). + +No other plugin or host service is interrupted. + +#### 25.4.2 Hot Uninstall + +When a plugin is uninstalled at runtime: + +1. The host sends `shutdown()` and follows the graceful shutdown policy (Section 12.5). +2. The host removes the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations from the live routing tables. +3. The host removes the plugin's UI bundle from the extension slot registry. Any currently mounted plugin UI components are unmounted and replaced with a placeholder or removed entirely. +4. The host marks the plugin `uninstalled` and starts the data retention grace period (Section 25.1). + +No server restart is needed. + +#### 25.4.3 Hot Upgrade + +When a plugin is upgraded at runtime: + +1. The host follows the upgrade lifecycle (Section 25.3) — shut down old worker, start new worker. +2. If the new version changes event subscriptions, job schedules, webhook endpoints, or agent tools, the host atomically swaps the old registrations for the new ones. +3. If the new version ships an updated UI bundle, the host invalidates any cached bundle assets and notifies the frontend to reload plugin UI components. Active users see the updated UI on next navigation or via a live refresh notification. +4. If the manifest `apiVersion` is unchanged and no new capabilities are added, the upgrade completes without operator interaction. + +#### 25.4.4 Hot Config Change + +When an operator updates a plugin's instance config at runtime: + +1. The host writes the new config to `plugin_config`. +2. The host sends a `configChanged` notification to the running worker via IPC. +3. The worker receives the new config through `ctx.config` and applies it without restarting. If the plugin needs to re-initialize connections (e.g. a new API token), it does so internally. +4. If the plugin does not handle `configChanged`, the host restarts the worker process with the new config (graceful shutdown then restart). + +#### 25.4.5 Frontend Cache Invalidation + +The host must version plugin UI bundle URLs (e.g. `/_plugins/:pluginId/ui/:version/*` or content-hash-based paths) so that browser caches do not serve stale bundles after upgrade or reinstall. + +The host should emit a `plugin.ui.updated` event that the frontend listens for to trigger re-import of updated plugin modules without a full page reload. + +#### 25.4.6 Worker Process Management + +The host's plugin process manager must support: + +- starting a worker for a newly installed plugin without affecting other workers +- stopping a worker for an uninstalled plugin without affecting other workers +- replacing a worker during upgrade (stop old, start new) atomically from the routing table's perspective +- restarting a worker after crash without operator intervention (with backoff) + +Each worker process is independent. There is no shared process pool or batch restart mechanism. + +## 26. Plugin Observability + +### 26.1 Logging + +Plugin workers use `ctx.logger` to emit structured logs. The host captures these logs and stores them in a queryable format. + +Log storage rules: + +- Plugin logs are stored in a `plugin_logs` table or appended to a log file under the plugin's data directory. +- Each log entry includes: plugin ID, timestamp, level, message, and optional structured metadata. +- Logs are queryable from the plugin settings page in the UI. +- Logs have a configurable retention period (default: 7 days). +- The host captures `stdout` and `stderr` from the worker process as fallback logs even if the worker does not use `ctx.logger`. + +### 26.2 Health Dashboard + +The plugin settings page must show: + +- current worker status (running, error, stopped) +- uptime since last restart +- recent log entries +- job run history with success/failure rates +- webhook delivery history with success/failure rates +- last health check result and diagnostics +- resource usage if available (memory, CPU) + +### 26.3 Alerting + +The host should emit internal events when plugin health degrades. These use the `plugin.*` namespace (not core domain events) and do not appear in the core activity log: + +- `plugin.health.degraded` — worker reporting errors or failing health checks +- `plugin.health.recovered` — worker recovered from error state +- `plugin.worker.crashed` — worker process exited unexpectedly +- `plugin.worker.restarted` — worker restarted after crash + +These events can be consumed by other plugins (e.g. a notification plugin) or surfaced in the dashboard. + +## 27. Plugin Development And Testing + +### 27.1 `@paperclipai/plugin-test-harness` + +The host should publish a test harness package that plugin authors use for local development and testing. + +The test harness provides: + +- a mock host that implements the full SDK interface (`ctx.config`, `ctx.events`, `ctx.state`, etc.) +- ability to send synthetic events and verify handler responses +- ability to trigger job runs and verify side effects +- ability to simulate `getData` and `performAction` calls as if coming from the UI bridge +- ability to simulate `executeTool` calls as if coming from an agent run +- in-memory state and entity stores for assertions +- configurable capability sets for testing capability denial paths + +Example usage: + +```ts +import { createTestHarness } from "@paperclipai/plugin-test-harness"; +import manifest from "../dist/manifest.js"; +import { register } from "../dist/worker.js"; + +const harness = createTestHarness({ manifest, capabilities: manifest.capabilities }); +await register(harness.ctx); + +// Simulate an event +await harness.emit("issue.created", { issueId: "iss-1", projectId: "proj-1" }); + +// Verify state was written +const state = await harness.state.get({ pluginId: manifest.id, scopeKind: "issue", scopeId: "iss-1", namespace: "sync", stateKey: "external-id" }); +expect(state).toBeDefined(); + +// Simulate a UI data request +const data = await harness.getData("sync-health", { companyId: "comp-1" }); +expect(data.syncedCount).toBeGreaterThan(0); +``` + +### 27.2 Local Plugin Development + +For developing a plugin against a running Paperclip instance: + +- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin` +- The host watches the plugin directory for changes and restarts the worker on rebuild. +- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload. +- The plugin settings page shows real-time logs from the worker for debugging. + +### 27.3 Plugin Starter Template + +The host should publish a starter template (`create-paperclip-plugin`) that scaffolds: + +- `package.json` with correct `paperclipPlugin` keys +- manifest with placeholder values +- worker entry with SDK type imports and example event handler +- UI entry with example `DashboardWidget` using bridge hooks +- test file using the test harness +- build configuration (esbuild or similar) for both worker and UI bundles +- `.gitignore` and `tsconfig.json` + +## 28. Example Mappings + +This spec directly supports the following plugin types: + +- `@paperclip/plugin-workspace-files` +- `@paperclip/plugin-terminal` +- `@paperclip/plugin-git` +- `@paperclip/plugin-linear` +- `@paperclip/plugin-github-issues` +- `@paperclip/plugin-grafana` +- `@paperclip/plugin-runtime-processes` +- `@paperclip/plugin-stripe` + +## 29. Compatibility And Versioning + +### 29.1 API Version Rules + +1. Host supports one or more explicit plugin API versions. +2. Plugin manifest declares exactly one `apiVersion`. +3. Host rejects unsupported versions at install time. +4. Plugin upgrades are explicit operator actions. +5. Capability expansion requires explicit operator approval. + +### 29.2 SDK Versioning + +The host publishes a single SDK package for plugin authors: + +- `@paperclipai/plugin-sdk` — the complete plugin SDK + +The package uses subpath exports to separate worker and UI concerns: + +- `@paperclipai/plugin-sdk` — worker-side SDK (context, events, state, tools, logger, `definePlugin`, `z`) +- `@paperclipai/plugin-sdk/ui` — frontend SDK (bridge hooks, shared components, design tokens) + +A single package simplifies dependency management for plugin authors — one dependency, one version, one changelog. The subpath exports keep bundle separation clean: worker code imports from the root, UI code imports from `/ui`. Build tools tree-shake accordingly so the worker bundle does not include React components and the UI bundle does not include worker-only code. + +Versioning rules: + +1. **Semver**: The SDK follows strict semantic versioning. Major version bumps indicate breaking changes to either the worker or UI surface; minor versions add new features backwards-compatibly; patch versions are bug fixes only. +2. **Tied to API version**: Each major SDK version corresponds to exactly one plugin `apiVersion`. When `@paperclipai/plugin-sdk@2.x` ships, it targets `apiVersion: 2`. Plugins built with SDK 1.x continue to declare `apiVersion: 1`. +3. **Host multi-version support**: The host must support at least the current and one previous `apiVersion` simultaneously. This means plugins built against the previous SDK major version continue to work without modification. The host maintains separate IPC protocol handlers for each supported API version. +4. **Minimum SDK version in manifest**: Plugins declare `sdkVersion` in the manifest as a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time and warns if the plugin's declared range is outside the host's supported SDK versions. +5. **Deprecation timeline**: When a new `apiVersion` ships, the previous version enters a deprecation period of at least 6 months. During this period: + - The host continues to load plugins targeting the deprecated version. + - The host logs a deprecation warning at plugin startup. + - The plugin settings page shows a banner indicating the plugin should be upgraded. + - After the deprecation period ends, the host may drop support for the old version in a future release. +6. **SDK changelog and migration guides**: Each major SDK release must include a migration guide documenting every breaking change, the new API surface, and a step-by-step upgrade path for plugin authors. +7. **UI surface stability**: Breaking changes to shared UI components (removing a component, changing required props) or design tokens require a major version bump just like worker API changes. The single-package model means both surfaces are versioned together, avoiding drift between worker and UI compatibility. + +### 29.3 Version Compatibility Matrix + +The host should publish a compatibility matrix: + +| Host Version | Supported API Versions | SDK Range | +|---|---|---| +| 1.0 | 1 | 1.x | +| 2.0 | 1, 2 | 1.x, 2.x | +| 3.0 | 2, 3 | 2.x, 3.x | + +This matrix is published in the host docs and queryable via `GET /api/plugins/compatibility`. + +### 29.4 Plugin Author Workflow + +When a new SDK version is released: + +1. Plugin author updates `@paperclipai/plugin-sdk` dependency. +2. Plugin author follows the migration guide to update code. +3. Plugin author updates `apiVersion` and `sdkVersion` in the manifest. +4. Plugin author publishes a new plugin version. +5. Operators upgrade the plugin on their instances. The old version continues to work until explicitly upgraded. + +## 30. Recommended Delivery Order + +## Phase 1 + +- plugin manifest +- install/list/remove/upgrade CLI +- global settings UI +- plugin process manager +- capability enforcement +- `plugins`, `plugin_config`, `plugin_state`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries` +- event bus +- jobs +- webhooks +- settings page +- plugin UI bundle loading, host bridge, and `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- bridge error propagation (`PluginBridgeError`) +- auto-generated settings form from `instanceConfigSchema` +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- `@paperclipai/plugin-test-harness` +- `create-paperclip-plugin` starter template +- uninstall with data retention grace period +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase is enough for: + +- Linear +- GitHub Issues +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- process/server tracking + +Workspace plugins (file browser, terminal, git, process tracking) do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2 + +- optional `plugin_entities` +- richer action systems +- trusted-module migration path if truly needed +- iframe-based isolation for untrusted plugin UI bundles +- plugin ecosystem/distribution work + +## 31. Final Design Decision + +Paperclip should not implement a generic in-process hook bag modeled directly after local coding tools. + +Paperclip should implement: + +- trusted platform modules for low-level host integration +- globally installed out-of-process plugins for additive instance-wide capabilities +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- auto-generated settings UI from config schema, with custom settings pages as an option +- plugin-to-plugin events for cross-plugin coordination +- server-side event filtering for efficient event routing +- plugins own their local tooling logic (filesystem, git, terminal, processes) directly +- generic extension tables for most plugin state +- graceful shutdown, uninstall data lifecycle, and plugin observability +- hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart +- SDK versioning with multi-version host support and a clear deprecation policy +- test harness and starter template for low authoring friction +- strict preservation of core governance and audit rules + +That is the complete target design for the Paperclip plugin system. diff --git a/doc/plugins/ideas-from-opencode.md b/doc/plugins/ideas-from-opencode.md new file mode 100644 index 00000000..fcef3c62 --- /dev/null +++ b/doc/plugins/ideas-from-opencode.md @@ -0,0 +1,1738 @@ +# Plugin Ideas From OpenCode + +Status: design report, not a V1 commitment + +Paperclip V1 explicitly excludes a plugin framework in [doc/SPEC-implementation.md](../SPEC-implementation.md), but the long-horizon spec says the architecture should leave room for extensions. This report studies the `opencode` plugin system and translates the useful patterns into a Paperclip-shaped design. + +Assumption for this document: Paperclip is a single-tenant operator-controlled instance. Plugin installation should therefore be global across the instance. "Companies" are still first-class Paperclip objects, but they are organizational records, not tenant-isolation boundaries for plugin trust or installation. + +## Executive Summary + +`opencode` has a real plugin system already. It is intentionally low-friction: + +- plugins are plain JS/TS modules +- they load from local directories and npm packages +- they can hook many runtime events +- they can add custom tools +- they can extend provider auth flows +- they run in-process and can mutate runtime behavior directly + +That model works well for a local coding tool. It should not be copied literally into Paperclip. + +The main conclusion is: + +- Paperclip should copy `opencode`'s typed SDK, deterministic loading, low authoring friction, and clear extension surfaces. +- Paperclip should not copy `opencode`'s trust model, project-local plugin loading, "override by name collision" behavior, or arbitrary in-process mutation hooks for core business logic. +- Paperclip should use multiple extension classes instead of one generic plugin bag: + - trusted in-process modules for low-level platform concerns like agent adapters, storage providers, secret providers, and possibly run-log backends + - out-of-process plugins for most third-party integrations like Linear, GitHub Issues, Grafana, Stripe, and schedulers + - plugin-contributed agent tools (namespaced, not override-by-collision) + - plugin-shipped React UI loaded into host extension slots via a typed bridge + - a typed event bus with server-side filtering and plugin-to-plugin events, plus scheduled jobs for automation + +If Paperclip does this well, the examples you listed become straightforward: + +- file browser / terminal / git workflow / child process tracking become workspace plugins that resolve paths from the host and handle OS operations directly +- Linear / GitHub / Grafana / Stripe become connector plugins +- future knowledge base and accounting features can also fit the same model + +## Sources Examined + +I cloned `anomalyco/opencode` and reviewed commit: + +- `a965a062595403a8e0083e85770315d5dc9628ab` + +Primary files reviewed: + +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/tool.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/plugin/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/config/config.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/tool/registry.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/provider/auth.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/plugins.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/custom-tools.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/ecosystem.mdx` + +Relevant Paperclip files reviewed for current extension seams: + +- [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) +- [doc/SPEC.md](../SPEC.md) +- [doc/SPEC-implementation.md](../SPEC-implementation.md) + +## What OpenCode Actually Implements + +## 1. Plugin authoring API + +`opencode` exposes a small package, `@opencode-ai/plugin`, with a typed `Plugin` function and a typed `tool()` helper. + +Core shape: + +- a plugin is an async function that receives a context object +- the plugin returns a `Hooks` object +- hooks are optional +- plugins can also contribute tools and auth providers + +The plugin init context includes: + +- an SDK client +- current project info +- current directory +- current git worktree +- server URL +- Bun shell access + +That is important: `opencode` gives plugins rich runtime power immediately, not a narrow capability API. + +## 2. Hook model + +The hook set is broad. It includes: + +- event subscription +- config-time hook +- message hooks +- model parameter/header hooks +- permission decision hooks +- shell env injection +- tool execution before/after hooks +- tool definition mutation +- compaction prompt customization +- text completion transforms + +The implementation pattern is very simple: + +- core code constructs an `output` object +- each matching plugin hook runs sequentially +- hooks mutate the `output` +- final mutated output is used by core + +This is elegant and easy to extend. + +It is also extremely powerful. A plugin can change auth headers, model params, permission answers, tool inputs, tool descriptions, and shell environment. + +## 3. Plugin discovery and load order + +`opencode` supports two plugin sources: + +- local files +- npm packages + +Local directories: + +- `~/.config/opencode/plugins/` +- `.opencode/plugins/` + +Npm plugins: + +- listed in config under `plugin: []` + +Load order is deterministic and documented: + +1. global config +2. project config +3. global plugin directory +4. project plugin directory + +Important details: + +- config arrays are concatenated rather than replaced +- duplicate plugin names are deduplicated with higher-precedence entries winning +- internal first-party plugins and default plugins are also loaded through the plugin pipeline + +This gives `opencode` a real precedence model rather than "whatever loaded last by accident." + +## 4. Dependency handling + +For local config/plugin directories, `opencode` will: + +- ensure a `package.json` exists +- inject `@opencode-ai/plugin` +- run `bun install` + +That lets local plugins and local custom tools import dependencies. + +This is excellent for local developer ergonomics. + +It is not a safe default for an operator-controlled control plane server. + +## 5. Error handling + +Plugin load failures do not hard-crash the runtime by default. + +Instead, `opencode`: + +- logs the error +- publishes a session error event +- continues loading other plugins + +That is a good operational pattern. One bad plugin should not brick the entire product unless the operator has explicitly configured it as required. + +## 6. Tools are a first-class extension point + +`opencode` has two ways to add tools: + +- export tools directly from a plugin via `hook.tool` +- define local files in `.opencode/tools/` or global tools directories + +The tool API is strong: + +- tools have descriptions +- tools have Zod schemas +- tool execution gets context like session ID, message ID, directory, and worktree +- tools are merged into the same registry as built-in tools +- tool definitions themselves can be mutated by a `tool.definition` hook + +The most aggressive part of the design: + +- custom tools can override built-in tools by name + +That is very powerful for a local coding assistant. +It is too dangerous for Paperclip core actions. + +However, the concept of plugins contributing agent-usable tools is very valuable for Paperclip — as long as plugin tools are namespaced (cannot shadow core tools) and capability-gated. + +## 7. Auth is also a plugin surface + +`opencode` allows plugins to register auth methods for providers. + +A plugin can contribute: + +- auth method metadata +- prompt flows +- OAuth flows +- API key flows +- request loaders that adapt provider behavior after auth succeeds + +This is a strong pattern worth copying. Integrations often need custom auth UX and token handling. + +## 8. Ecosystem evidence + +The ecosystem page is the best proof that the model is working in practice. +Community plugins already cover: + +- sandbox/workspace systems +- auth providers +- session headers / telemetry +- memory/context features +- scheduling +- notifications +- worktree helpers +- background agents +- monitoring + +That validates the main thesis: a simple typed plugin API can create real ecosystem velocity. + +## What OpenCode Gets Right + +## 1. Separate plugin SDK from host runtime + +This is one of the best parts of the design. + +- plugin authors code against a clean public package +- host internals can evolve behind the loader +- runtime code and plugin code have a clean contract boundary + +Paperclip should absolutely do this. + +## 2. Deterministic loading and precedence + +`opencode` is explicit about: + +- where plugins come from +- how config merges +- what order wins + +Paperclip should copy this discipline. + +## 3. Low-ceremony authoring + +A plugin author does not have to learn a giant framework. + +- export async function +- return hooks +- optionally export tools + +That simplicity matters. + +## 4. Typed tool definitions + +The `tool()` helper is excellent: + +- typed +- schema-based +- easy to document +- easy for runtime validation + +Paperclip should adopt this style for plugin actions, automations, and UI schemas. + +## 5. Built-in features and plugins use similar shapes + +`opencode` uses the same hook system for internal and external plugin-style behavior in several places. +That reduces special cases. + +Paperclip can benefit from that with adapters, secret backends, storage providers, and connector modules. + +## 6. Incremental extension, not giant abstraction upfront + +`opencode` did not design a giant marketplace platform first. +It added concrete extension points that real features needed. + +That is the correct mindset for Paperclip too. + +## What Paperclip Should Not Copy Directly + +## 1. In-process arbitrary plugin code as the default + +`opencode` is basically a local agent runtime, so unsandboxed plugin execution is acceptable for its audience. + +Paperclip is a control plane for an operator-managed instance with company objects. +The risk profile is different: + +- secrets matter +- approval gates matter +- budgets matter +- mutating actions require auditability + +Default third-party plugins should not run with unrestricted in-process access to server memory, DB handles, and secrets. + +## 2. Project-local plugin loading + +`opencode` has project-local plugin folders because the tool is centered around a codebase. + +Paperclip is not project-scoped. It is instance-scoped. +The comparable unit is: + +- instance-installed plugin package + +Paperclip should not auto-load arbitrary code from a workspace repo like `.paperclip/plugins` or project directories. + +## 3. Arbitrary mutation hooks on core business decisions + +Hooks like: + +- `permission.ask` +- `tool.execute.before` +- `chat.headers` +- `shell.env` + +make sense in `opencode`. + +For Paperclip, equivalent hooks into: + +- approval decisions +- issue checkout semantics +- activity log behavior +- budget enforcement + +would be a mistake. + +Core invariants should stay in core code, not become hook-rewritable. + +## 4. Override-by-name collision + +Allowing a plugin to replace a built-in tool by name is useful in a local agent product. + +Paperclip should not allow plugins to silently replace: + +- core routes +- core mutating actions +- auth behaviors +- permission evaluators +- budget logic +- audit logic + +Extension should be additive or explicitly delegated, never accidental shadowing. + +## 5. Auto-install and execute from user config + +`opencode`'s "install dependencies at startup" flow is ergonomic. +For Paperclip it would be risky because it combines: + +- package installation +- code loading +- execution + +inside the control-plane server startup path. + +Paperclip should require an explicit operator install step. + +## Why Paperclip Needs A Different Shape + +The products are solving different problems. + +| Topic | OpenCode | Paperclip | +|---|---|---| +| Primary unit | local project/worktree | single-tenant operator instance with company objects | +| Trust assumption | local power user on own machine | operator managing one trusted Paperclip instance | +| Failure blast radius | local session/runtime | entire company control plane | +| Extension style | mutate runtime behavior freely | preserve governance and auditability | +| UI model | local app can load local behavior | board UI must stay coherent and safe | +| Security model | host-trusted local plugins | needs capability boundaries and auditability | + +That means Paperclip should borrow the good ideas from `opencode` but use a stricter architecture. + +## Paperclip Already Has Useful Pre-Plugin Seams + +Paperclip has several extension-like seams already: + +- server adapter registry: [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- UI adapter registry: [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- storage provider registry: [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- secret provider registry: [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- pluggable run-log store seam: [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- activity log and live event emission: [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) + +This is good news. +Paperclip does not need to invent extensibility from scratch. +It needs to unify and harden existing seams. + +## Recommended Paperclip Plugin Model + +## 1. Use multiple extension classes + +Do not create one giant `hooks` object for everything. + +Use distinct plugin classes with different trust models. + +| Extension class | Examples | Runtime model | Trust level | Why | +|---|---|---|---|---| +| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | +| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | +| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | out-of-process, direct OS access | medium | resolves workspace paths from host, owns filesystem/git/PTY/process logic directly | +| UI contribution | dashboard widgets, settings forms, company panels | plugin-shipped React bundles in host extension slots via bridge | medium | plugins own their rendering; host controls slot placement and bridge access | +| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | + +This split is the most important design recommendation in this report. + +## 2. Keep low-level modules separate from third-party plugins + +Paperclip already has this pattern implicitly: + +- adapters are one thing +- storage providers are another +- secret providers are another + +Keep that separation. + +I would formalize it like this: + +- `module` means trusted code loaded by the host for low-level runtime services +- `plugin` means integration code that talks to Paperclip through a typed plugin protocol and capability model + +This avoids trying to force Stripe, a PTY terminal, and a new agent adapter into the same abstraction. + +## 3. Prefer event-driven extensions over core-logic mutation + +For third-party plugins, the primary API should be: + +- subscribe to typed domain events (with optional server-side filtering) +- emit plugin-namespaced events for cross-plugin communication +- read instance state, including company-bound business records when relevant +- register webhooks +- run scheduled jobs +- contribute tools that agents can use during runs +- write plugin-owned state +- add additive UI surfaces +- invoke explicit Paperclip actions through the API + +Do not make third-party plugins responsible for: + +- deciding whether an approval passes +- intercepting issue checkout semantics +- rewriting activity log behavior +- overriding budget hard-stops + +Those are core invariants. + +## 4. Plugins ship their own UI + +Plugins ship their own React UI as a bundled module inside `dist/ui/`. The host loads plugin components into designated **extension slots** (pages, tabs, widgets, sidebar entries) and provides a **bridge** for the plugin frontend to talk to its own worker backend and to access host context. + +**How it works:** + +1. The plugin's UI exports named components for each slot it fills (e.g. `DashboardWidget`, `IssueDetailTab`, `SettingsPage`). +2. The host mounts the plugin component into the correct slot, passing a bridge object with hooks like `usePluginData(key, params)` and `usePluginAction(key)`. +3. The plugin component fetches data from its own worker via the bridge and renders it however it wants. +4. The host enforces capability gates through the bridge — if the worker doesn't have a capability, the bridge rejects the call. + +**What the host controls:** where plugin components appear, the bridge API, capability enforcement, and shared UI primitives (`@paperclipai/plugin-sdk/ui`) with design tokens and common components. + +**What the plugin controls:** how to render its data, what data to fetch, what actions to expose, and whether to use the host's shared components or build entirely custom UI. + +First version extension slots: + +- dashboard widgets +- settings pages +- detail-page tabs (project, issue, agent, goal, run) +- sidebar entries +- company-context plugin pages + +The host SDK ships shared components (MetricCard, DataTable, StatusBadge, LogView, etc.) for visual consistency, but these are optional. + +Later, if untrusted third-party plugins become common, the host can move to iframe-based isolation without changing the plugin's source code (the bridge API stays the same). + +## 5. Make installation global and keep mappings/config separate + +`opencode` is mostly user-level local config. +Paperclip should treat plugin installation as a global instance-level action. + +Examples: + +- install `@paperclip/plugin-linear` once +- make it available everywhere immediately +- optionally store mappings over Paperclip objects if one company maps to a different Linear team than another + +## 6. Use project workspaces as the primary anchor for local tooling + +Paperclip already has a concrete workspace model for projects: + +- projects expose `workspaces` and `primaryWorkspace` +- the database already has `project_workspaces` +- project routes already support creating, updating, and deleting workspaces +- heartbeat resolution already prefers project workspaces before falling back to task-session or agent-home workspaces + +That means local/runtime plugins should generally anchor themselves to projects first, not invent a parallel workspace model. + +Practical guidance: + +- file browser should browse project workspaces first +- terminal sessions should be launchable from a project workspace +- git should treat the project workspace as the repo root anchor +- dev server and child-process tracking should attach to project workspaces +- issue and agent views can still deep-link into the relevant project workspace context + +In other words: + +- `project` is the business object +- `project_workspace` is the local runtime anchor +- plugins should build on that instead of creating an unrelated workspace model first + +## 7. Let plugins contribute agent tools + +`opencode` makes tools a first-class extension point. This is one of the highest-value surfaces for Paperclip too. + +A Linear plugin should be able to contribute a `search-linear-issues` tool that agents use during runs. A git plugin should contribute `create-branch` and `get-diff`. A file browser plugin should contribute `read-file` and `list-directory`. + +The key constraints: + +- plugin tools are namespaced by plugin ID (e.g. `linear:search-issues`) so they cannot shadow core tools +- plugin tools require the `agent.tools.register` capability +- tool execution goes through the same worker RPC boundary as everything else +- tool results appear in run logs + +This is a natural fit — the plugin already has the SDK context, the external API credentials, and the domain logic. Wrapping that in a tool definition is minimal additional work for the plugin author. + +## 8. Support plugin-to-plugin events + +Plugins should be able to emit custom events that other plugins can subscribe to. For example, the git plugin detects a push and emits `plugin.@paperclip/plugin-git.push-detected`. The GitHub Issues plugin subscribes to that event and updates PR links. + +This avoids plugins needing to coordinate through shared state or external channels. The host routes plugin events through the same event bus with the same delivery semantics as core events. + +Plugin events use a `plugin..*` namespace so they cannot collide with core events. + +## 9. Auto-generate settings UI from config schema + +Plugins that declare an `instanceConfigSchema` should get an auto-generated settings form for free. The host renders text inputs, dropdowns, toggles, arrays, and secret-ref pickers directly from the JSON Schema. + +For plugins that need richer settings UX, they can declare a `settingsPage` extension slot and ship a custom React component. Both approaches coexist. + +This matters because settings forms are boilerplate that every plugin needs. Auto-generating them from the schema that already exists removes a significant chunk of authoring friction. + +## 10. Design for graceful shutdown and upgrade + +The spec should be explicit about what happens when a plugin worker stops — during upgrades, uninstalls, or instance restarts. + +The recommended policy: + +- send `shutdown()` with a configurable deadline (default 10 seconds) +- SIGTERM after deadline, SIGKILL after 5 more seconds +- in-flight jobs marked `cancelled` +- in-flight bridge calls return structured errors to the UI + +For upgrades specifically: the old worker drains, the new worker starts. If the new version adds capabilities, it enters `upgrade_pending` until the operator approves. + +## 11. Define uninstall data lifecycle + +When a plugin is uninstalled, its data (`plugin_state`, `plugin_entities`, `plugin_jobs`, etc.) should be retained for a grace period (default 30 days), not immediately deleted. The operator can reinstall within the grace period and recover state, or force-purge via CLI. + +This matters because accidental uninstalls should not cause irreversible data loss. + +## 12. Invest in plugin observability + +Plugin logs via `ctx.logger` should be stored and queryable from the plugin settings page. The host should also capture raw `stdout`/`stderr` from the worker process as fallback. + +The plugin health dashboard should show: worker status, uptime, recent logs, job success/failure rates, webhook delivery rates, and resource usage. The host should emit internal events (`plugin.health.degraded`, `plugin.worker.crashed`) that other plugins or dashboards can consume. + +This is critical for operators. Without observability, debugging plugin issues requires SSH access and manual log tailing. + +## 13. Ship a test harness and starter template + +A `@paperclipai/plugin-test-harness` package should provide a mock host with in-memory stores, synthetic event emission, and `getData`/`performAction`/`executeTool` simulation. Plugin authors should be able to write unit tests without a running Paperclip instance. + +A `create-paperclip-plugin` CLI should scaffold a working plugin with manifest, worker, UI bundle, test file, and build config. + +Low authoring friction was called out as one of `opencode`'s best qualities. The test harness and starter template are how Paperclip achieves the same. + +## 14. Support hot plugin lifecycle + +Plugin install, uninstall, upgrade, and config changes should take effect without restarting the Paperclip server. This is critical for developer workflow and operator experience. + +The out-of-process worker architecture makes this natural: + +- **Hot install**: spawn a new worker, register its event subscriptions, job schedules, webhook endpoints, and agent tools in live routing tables, load its UI bundle into the extension slot registry. +- **Hot uninstall**: graceful shutdown of the worker, remove all registrations from routing tables, unmount UI components, start data retention grace period. +- **Hot upgrade**: shut down old worker, start new worker, atomically swap routing table entries, invalidate UI bundle cache so the frontend loads the updated bundle. +- **Hot config change**: write new config to `plugin_config`, notify the running worker via IPC (`configChanged`). The worker applies the change without restarting. If it doesn't handle `configChanged`, the host restarts just that worker. + +Frontend cache invalidation uses versioned or content-hashed bundle URLs and a `plugin.ui.updated` event that triggers re-import without a full page reload. + +Each worker process is independent — starting, stopping, or replacing one worker never affects any other plugin or the host itself. + +## 15. Define SDK versioning and compatibility + +`opencode` does not have a formal SDK versioning story because plugins run in-process and are effectively pinned to the current runtime. Paperclip's out-of-process model means plugins may be built against one SDK version and run on a host that has moved forward. This needs explicit rules. + +Recommended approach: + +- **Single SDK package**: `@paperclipai/plugin-sdk` with subpath exports — root for worker code, `/ui` for frontend code. One dependency, one version, one changelog. +- **SDK major version = API version**: `@paperclipai/plugin-sdk@2.x` targets `apiVersion: 2`. Plugins built with SDK 1.x declare `apiVersion: 1` and continue to work. +- **Host multi-version support**: The host supports at least the current and one previous `apiVersion` simultaneously with separate IPC protocol handlers per version. +- **`sdkVersion` in manifest**: Plugins declare a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time. +- **Deprecation timeline**: Previous API versions get at least 6 months of continued support after a new version ships. The host logs deprecation warnings and shows a banner on the plugin settings page. +- **Migration guides**: Each major SDK release ships with a step-by-step migration guide covering every breaking change. +- **UI surface versioned with worker**: Both worker and UI surfaces are in the same package, so they version together. Breaking changes to shared UI components require a major version bump just like worker API changes. +- **Published compatibility matrix**: The host publishes a matrix of supported API versions and SDK ranges, queryable via API. + +## A Concrete SDK Shape For Paperclip + +An intentionally narrow first pass could look like this: + +```ts +import { definePlugin, z } from "@paperclipai/plugin-sdk"; + +export default definePlugin({ + id: "@paperclip/plugin-linear", + version: "0.1.0", + categories: ["connector", "ui"], + capabilities: [ + "events.subscribe", + "jobs.schedule", + "http.outbound", + "instance.settings.register", + "ui.dashboardWidget.register", + "secrets.read-ref", + ], + instanceConfigSchema: z.object({ + linearBaseUrl: z.string().url().optional(), + companyMappings: z.array( + z.object({ + companyId: z.string(), + teamId: z.string(), + apiTokenSecretRef: z.string(), + }), + ).default([]), + }), + async register(ctx) { + ctx.jobs.register("linear-pull", { cron: "*/5 * * * *" }, async (job) => { + // sync Linear issues into plugin-owned state or explicit Paperclip entities + }); + + // subscribe with optional server-side filter + ctx.events.on("issue.created", { projectId: "proj-1" }, async (event) => { + // only receives issue.created events for project proj-1 + }); + + // subscribe to events from another plugin + ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push + }); + + // contribute a tool that agents can use during runs + ctx.tools.register("search-linear-issues", { + displayName: "Search Linear Issues", + description: "Search for Linear issues by query", + parametersSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, async (params, runCtx) => { + // search Linear API and return results + return { content: JSON.stringify(results) }; + }); + + // getData is called by the plugin's own UI components via the host bridge + ctx.data.register("sync-health", async ({ companyId }) => { + // return typed JSON that the plugin's DashboardWidget component renders + return { syncedCount: 142, trend: "+12 today", mappings: [...] }; + }); + + ctx.actions.register("resync", async ({ companyId }) => { + // run sync logic + }); + }, +}); +``` + +The plugin's UI bundle (separate from the worker) might look like: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, ErrorBoundary } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading, error } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + if (error) return
Plugin error: {error.message} ({error.code})
; + + return ( + Widget failed to render}> + + + + ); +} +``` + +The important point is not the exact syntax. +The important point is the contract shape: + +- typed manifest +- explicit capabilities +- explicit global config with optional company mappings +- event subscriptions with optional server-side filtering +- plugin-to-plugin events via namespaced event types +- agent tool contributions +- jobs +- plugin-shipped UI that communicates with its worker through the host bridge +- structured error propagation from worker to UI + +## Recommended Core Extension Surfaces + +## 1. Platform module surfaces + +These should stay close to the current registry style. + +Candidates: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +These are trusted platform modules, not casual plugins. + +## 2. Connector plugin surfaces + +These are the best near-term plugin candidates. + +Capabilities: + +- subscribe to domain events +- define scheduled sync jobs +- expose plugin-specific API routes under `/api/plugins/:pluginId/...` +- use company secret refs +- write plugin state +- publish dashboard data +- log activity through core APIs + +Examples: + +- Linear issue sync +- GitHub issue sync +- Grafana dashboard cards +- Stripe MRR / subscription rollups + +## 3. Workspace-runtime surfaces + +Workspace plugins handle local tooling directly: + +- file browser +- terminal +- git workflow +- child process tracking +- local dev server tracking + +Plugins resolve workspace paths through host APIs (`ctx.projects` provides workspace metadata including `cwd`, `repoUrl`, etc.) and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +The host does not wrap or proxy these operations. This keeps the core lean — no need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own implementations. + +## Governance And Safety Requirements + +Any Paperclip plugin system has to preserve core control-plane invariants from the repo docs. + +That means: + +- plugin install is global to the instance +- "companies" remain business objects in the API and data model, not tenant boundaries +- approval gates remain core-owned +- budget hard-stops remain core-owned +- mutating actions are activity-logged +- secrets remain ref-based and redacted in logs + +I would require the following for every plugin: + +## 1. Capability declaration + +Every plugin declares a static capability set such as: + +- `companies.read` +- `issues.read` +- `issues.write` +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `http.outbound` +- `webhooks.receive` +- `assets.read` +- `assets.write` +- `secrets.read-ref` +- `agent.tools.register` +- `plugin.state.read` +- `plugin.state.write` + +The board/operator sees this before installation. + +## 2. Global installation + +A plugin is installed once and becomes available across the instance. +If it needs mappings over specific Paperclip objects, those are plugin data, not enable/disable boundaries. + +## 3. Activity logging + +Plugin-originated mutations should flow through the same activity log mechanism, with a dedicated `plugin` actor type: + +- `actor_type = plugin` +- `actor_id = ` (e.g. `@paperclip/plugin-linear`) + +## 4. Health and failure reporting + +Each plugin should expose: + +- enabled/disabled state +- last successful run +- last error +- recent webhook/job history + +One broken plugin must not break the rest of the company. + +## 5. Secret handling + +Plugins should receive secret refs, not raw secret values in config persistence. +Resolution should go through the existing secret provider abstraction. + +## 6. Resource limits + +Plugins should have: + +- timeout limits +- concurrency limits +- retry policies +- optional per-plugin budgets + +This matters especially for sync connectors and workspace plugins. + +## Data Model Additions To Consider + +I would avoid "arbitrary third-party plugin-defined SQL migrations" in the first version. +That is too much power too early. + +The right mental model is: + +- reuse core tables when the data is clearly part of Paperclip itself +- use generic extension tables for most plugin-owned state +- only allow plugin-specific tables later, and only for trusted platform modules or a tightly controlled migration workflow + +## Recommended Postgres Strategy For Extensions + +### 1. Core tables stay core + +If a concept is becoming part of Paperclip's actual product model, it should get a normal first-party table. + +Examples: + +- `project_workspaces` is already a core table because project workspaces are now part of Paperclip itself +- if a future "project git state" becomes a core feature rather than plugin-owned metadata, that should also be a first-party table + +### 2. Most plugins should start in generic extension tables + +For most plugins, the host should provide a few generic persistence tables and the plugin stores namespaced records there. + +This keeps the system manageable: + +- simpler migrations +- simpler backup/restore +- simpler portability story +- easier operator review +- fewer chances for plugin schema drift to break the instance + +### 3. Scope plugin data to Paperclip objects before adding custom schemas + +A lot of plugin data naturally hangs off existing Paperclip objects: + +- project workspace plugin state should often scope to `project` or `project_workspace` +- issue sync state should scope to `issue` +- metrics widgets may scope to `company`, `project`, or `goal` +- process tracking may scope to `project_workspace`, `agent`, or `run` + +That gives a good default keying model before introducing custom tables. + +### 4. Add trusted module migrations later, not arbitrary plugin migrations now + +If Paperclip eventually needs extension-owned tables, I would only allow that for: + +- trusted first-party packages +- trusted platform modules +- maybe explicitly installed admin-reviewed plugins with pinned versions + +I would not let random third-party plugins run free-form schema migrations on startup. + +Instead, add a controlled mechanism later if it becomes necessary. + +## Suggested baseline extension tables + +## 1. `plugins` + +Instance-level installation record. + +Suggested fields: + +- `id` +- `package_name` +- `version` +- `categories` +- `manifest_json` +- `installed_at` +- `status` + +## 2. `plugin_config` + +Instance-level plugin config. + +Suggested fields: + +- `id` +- `plugin_id` +- `config_json` +- `created_at` +- `updated_at` +- `last_error` + +## 3. `plugin_state` + +Generic key/value state for plugins. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` (`instance | company | project | project_workspace | agent | issue | goal | run`) +- `scope_id` nullable +- `namespace` +- `state_key` +- `value_json` +- `updated_at` + +This is enough for many connectors before allowing custom tables. + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +## 4. `plugin_jobs` + +Scheduled job and run tracking. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `job_key` +- `status` +- `last_started_at` +- `last_finished_at` +- `last_error` + +## 5. `plugin_webhook_deliveries` + +If plugins expose webhooks, delivery history is worth storing. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `endpoint_key` +- `status` +- `received_at` +- `response_code` +- `error` + +## 6. Maybe later: `plugin_entities` + +If generic plugin state becomes too limiting, add a structured, queryable entity table for connector records before allowing arbitrary plugin migrations. + +Suggested fields: + +- `id` +- `plugin_id` +- `entity_type` +- `scope_kind` +- `scope_id` +- `external_id` +- `title` +- `status` +- `data_json` +- `updated_at` + +This is a useful middle ground: + +- much more queryable than opaque key/value state +- still avoids letting every plugin create its own relational schema immediately + +## How The Requested Examples Map To This Model + +| Use case | Best fit | Host primitives needed | Notes | +|---|---|---|---| +| File browser | workspace plugin | project workspace metadata | plugin owns filesystem ops directly | +| Terminal | workspace plugin | project workspace metadata | plugin spawns PTY sessions directly | +| Git workflow | workspace plugin | project workspace metadata | plugin shells out to git directly | +| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | +| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | +| Grafana metrics | connector plugin + dashboard widget | outbound HTTP | probably read-only first | +| Child process/server tracking | workspace plugin | project workspace metadata | plugin manages processes directly | +| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate | + +# Plugin Examples + +## Workspace File Browser + +Package idea: `@paperclip/plugin-workspace-files` + +This plugin lets the board inspect project workspaces, agent workspaces, generated artifacts, and issue-related files without dropping to the shell. It is useful for: + +- browsing files inside project workspaces +- debugging what an agent changed +- reviewing generated outputs before approval +- attaching files from a workspace to issues +- understanding repo layout for a company +- inspecting agent home workspaces in local-trusted mode + +### UX + +- Settings page: `/settings/plugins/workspace-files` +- Main page: `/:companyPrefix/plugins/workspace-files` +- Project tab: `/:companyPrefix/projects/:projectId?tab=files` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=files` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=workspace` + +Main screens and interactions: + +- Plugin settings: + - choose whether the plugin defaults to `project.primaryWorkspace` + - choose which project workspaces are visible + - choose whether file writes are allowed or read-only + - choose whether hidden files are visible +- Main explorer page: + - project picker at the top + - workspace picker scoped to the selected project's `workspaces` + - tree view on the left + - file preview pane on the right + - search box for filename/path search + - actions: copy path, download file, attach to issue, open diff +- Project tab: + - opens directly into the project's primary workspace + - lets the board switch among all project workspaces + - shows workspace metadata like `cwd`, `repoUrl`, and `repoRef` +- Issue tab: + - resolves the issue's project and opens that project's workspace context + - shows files linked to the issue + - lets the board pull files from the project workspace into issue attachments + - shows the path and last modified info for each linked file +- Agent tab: + - shows the agent's current resolved workspace + - if the run is attached to a project, links back to the project workspace view + - lets the board inspect files the agent is currently touching + +Core workflows: + +- Board opens a project and browses its primary workspace files. +- Board switches from one project workspace to another when a project has multiple checkouts or repo references. +- Board opens an issue, attaches a generated artifact from the file browser, and leaves a review comment. +- Board opens an agent detail page to inspect the exact files behind a failing run. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `projects.read` +- `project.workspaces.read` +- optional `assets.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all filesystem operations (read, write, stat, search, list directory) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` +- `events.subscribe(issue.attachment.created)` + +## Workspace Terminal + +Package idea: `@paperclip/plugin-terminal` + +This plugin gives the board a controlled terminal UI for project workspaces and agent workspaces. It is useful for: + +- debugging stuck runs +- verifying environment state +- running targeted manual commands +- watching long-running commands +- pairing a human operator with an agent workflow + +### UX + +- Settings page: `/settings/plugins/terminal` +- Main page: `/:companyPrefix/plugins/terminal` +- Project tab: `/:companyPrefix/projects/:projectId?tab=terminal` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=terminal` +- Optional run tab: `/:companyPrefix/agents/:agentId/runs/:runId?tab=terminal` + +Main screens and interactions: + +- Plugin settings: + - allowed shells and shell policy + - whether commands are read-only, free-form, or allow-listed + - whether terminals require an explicit operator confirmation before launch + - whether new terminal sessions default to the project's primary workspace +- Terminal home page: + - list of active terminal sessions + - button to open a new session + - project picker, then workspace picker from that project's workspaces + - optional agent association + - terminal panel with input, resize, and reconnect support + - controls: interrupt, kill, clear, save transcript +- Project terminal tab: + - opens a session already scoped to the project's primary workspace + - lets the board switch among the project's configured workspaces + - shows recent commands and related process/server state for that project +- Agent terminal tab: + - opens a session already scoped to the agent's workspace + - shows recent related runs and commands +- Run terminal tab: + - lets the board inspect the environment around a specific failed run + +Core workflows: + +- Board opens a terminal against an agent workspace to reproduce a failing command. +- Board opens a project page and launches a terminal directly in that project's primary workspace. +- Board watches a long-running dev server or test command from the terminal page. +- Board kills or interrupts a runaway process from the same UI. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `agent`, and `run` +- `projects.read` +- `project.workspaces.read` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles PTY session management (open, input, resize, terminate, subscribe) directly using Node PTY libraries. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.failed)` +- `events.subscribe(agent.run.cancelled)` + +## Git Workflow + +Package idea: `@paperclip/plugin-git` + +This plugin adds repo-aware workflow tooling around issues and workspaces. It is useful for: + +- branch creation tied to issues +- quick diff review +- commit and worktree visibility +- PR preparation +- treating the project's primary workspace as the canonical repo anchor +- seeing whether an agent's workspace is clean or dirty + +### UX + +- Settings page: `/settings/plugins/git` +- Main page: `/:companyPrefix/plugins/git` +- Project tab: `/:companyPrefix/projects/:projectId?tab=git` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=git` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=git` + +Main screens and interactions: + +- Plugin settings: + - branch naming template + - optional remote provider token secret ref + - whether write actions are enabled or read-only + - whether the plugin always uses `project.primaryWorkspace` unless a different project workspace is chosen +- Git overview page: + - project picker and workspace picker + - current branch + - ahead/behind status + - dirty files summary + - recent commits + - active worktrees + - actions: refresh, create branch, create worktree, stage all, commit, open diff +- Project tab: + - opens in the project's primary workspace + - shows workspace metadata and repo binding (`cwd`, `repoUrl`, `repoRef`) + - shows branch, diff, and commit history for that project workspace +- Issue tab: + - resolves the issue's project and uses that project's workspace context + - "create branch from issue" action + - diff view scoped to the project's selected workspace + - link branch/worktree metadata to the issue +- Agent tab: + - shows the agent's branch, worktree, and dirty state + - shows recent commits produced by that agent + - if the agent is working inside a project workspace, links back to the project git tab + +Core workflows: + +- Board creates a branch from an issue and ties it to the project's primary workspace. +- Board opens a project page and reviews the diff for that project's workspace without leaving Paperclip. +- Board reviews the diff after a run without leaving Paperclip. +- Board opens a worktree list to understand parallel branches across agents. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `ui.action.register` +- `projects.read` +- `project.workspaces.read` +- optional `agent.tools.register` (e.g. `create-branch`, `get-diff`, `get-status`) +- optional `events.emit` (e.g. `plugin.@paperclip/plugin-git.push-detected`) +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all git operations (status, diff, log, branch create, commit, worktree create, push) directly using git CLI or a git library. + +Optional event subscriptions: + +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(agent.run.finished)` + +The git plugin can emit `plugin.@paperclip/plugin-git.push-detected` events that other plugins (e.g. GitHub Issues) subscribe to for cross-plugin coordination. + +Note: GitHub/GitLab PR creation should likely live in a separate connector plugin rather than overloading the local git plugin. + +## Linear Issue Tracking + +Package idea: `@paperclip/plugin-linear` + +This plugin syncs Paperclip work with Linear. It is useful for: + +- importing backlog from Linear +- linking Paperclip issues to Linear issues +- syncing status, comments, and assignees +- mapping company goals/projects to external product planning +- giving board operators a single place to see sync health + +### UX + +- Settings page: `/settings/plugins/linear` +- Main page: `/:companyPrefix/plugins/linear` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=linear` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=linear` + +Main screens and interactions: + +- Plugin settings: + - Linear API token secret ref + - workspace/team/project mappings + - status mapping between Paperclip and Linear + - sync direction: import only, export only, bidirectional + - comment sync toggle +- Linear overview page: + - sync health card + - recent sync jobs + - mapped projects and teams + - unresolved conflicts queue + - import actions for teams, projects, and issues +- Issue tab: + - linked Linear issue key and URL + - sync status and last synced time + - actions: link existing, create in Linear, resync now, unlink + - timeline of synced comments/status changes +- Dashboard widget: + - open sync errors + - imported vs linked issues count + - recent webhook/job failures + +Core workflows: + +- Board enables the plugin, maps a Linear team, and imports a backlog into Paperclip. +- Paperclip issue status changes push to Linear and Linear comments arrive back through webhooks. +- Board resolves mapping conflicts from the plugin page instead of silently drifting state. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(project.updated)` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- optional `agent.tools.register` (e.g. `search-linear-issues`, `get-linear-issue`) +- `activity.log.write` + +Important constraint: + +- webhook processing should be idempotent and conflict-aware +- external IDs and sync cursors belong in plugin-owned state, not inline on core issue rows in the first version + +## GitHub Issue Tracking + +Package idea: `@paperclip/plugin-github-issues` + +This plugin syncs Paperclip issues with GitHub Issues and optionally links PRs. It is useful for: + +- importing repo backlogs +- mirroring issue status and comments +- linking PRs to Paperclip issues +- tracking cross-repo work from inside one company view +- bridging engineering workflow with Paperclip task governance + +### UX + +- Settings page: `/settings/plugins/github-issues` +- Main page: `/:companyPrefix/plugins/github-issues` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=github` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=github` + +Main screens and interactions: + +- Plugin settings: + - GitHub App or PAT secret ref + - org/repo mappings + - label/status mapping + - whether PR linking is enabled + - whether new Paperclip issues should create GitHub issues automatically +- GitHub overview page: + - repo mapping list + - sync health and recent webhook events + - import backlog action + - queue of unlinked GitHub issues +- Issue tab: + - linked GitHub issue and optional linked PRs + - actions: create GitHub issue, link existing issue, unlink, resync + - comment/status sync timeline +- Dashboard widget: + - open PRs linked to active Paperclip issues + - webhook failures + - sync lag metrics + +Core workflows: + +- Board imports GitHub Issues for a repo into Paperclip. +- GitHub webhooks update status/comment state in Paperclip. +- A PR is linked back to the Paperclip issue so the board can follow delivery status. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(plugin.@paperclip/plugin-git.push-detected)` (cross-plugin coordination) +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- `activity.log.write` + +Important constraint: + +- keep "local git state" and "remote GitHub issue state" in separate plugins even if they work together — cross-plugin events handle coordination + +## Grafana Metrics + +Package idea: `@paperclip/plugin-grafana` + +This plugin surfaces external metrics and dashboards inside Paperclip. It is useful for: + +- company KPI visibility +- infrastructure/incident monitoring +- showing deploy, traffic, latency, or revenue charts next to work +- creating Paperclip issues from anomalous metrics + +### UX + +- Settings page: `/settings/plugins/grafana` +- Main page: `/:companyPrefix/plugins/grafana` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional goal tab: `/:companyPrefix/goals/:goalId?tab=metrics` + +Main screens and interactions: + +- Plugin settings: + - Grafana base URL + - service account token secret ref + - dashboard and panel mappings + - refresh interval + - optional alert threshold rules +- Dashboard widgets: + - one or more metric cards on the main dashboard + - quick trend view and last refresh time + - link out to Grafana and link in to the full Paperclip plugin page +- Full metrics page: + - selected dashboard panels embedded or proxied + - metric selector + - time range selector + - "create issue from anomaly" action +- Goal tab: + - metric cards relevant to a specific goal or project + +Core workflows: + +- Board sees service degradation or business KPI movement directly on the Paperclip dashboard. +- Board clicks into the full metrics page to inspect the relevant Grafana panels. +- Board creates a Paperclip issue from a threshold breach with a metric snapshot attached. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `ui.detailTab.register` for `goal` or `project` +- `jobs.schedule` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `assets.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(goal.created)` +- `events.subscribe(project.updated)` + +Important constraint: + +- start read-only first +- do not make Grafana alerting logic part of Paperclip core; keep it as additive signal and issue creation + +## Child Process / Server Tracking + +Package idea: `@paperclip/plugin-runtime-processes` + +This plugin tracks long-lived local processes and dev servers started in project workspaces. It is useful for: + +- seeing which agent started which local service +- tracking ports, health, and uptime +- restarting failed dev servers +- exposing process state alongside issue and run state +- making local development workflows visible to the board + +### UX + +- Settings page: `/settings/plugins/runtime-processes` +- Main page: `/:companyPrefix/plugins/runtime-processes` +- Dashboard widget: `/:companyPrefix/dashboard` +- Process detail page: `/:companyPrefix/plugins/runtime-processes/:processId` +- Project tab: `/:companyPrefix/projects/:projectId?tab=processes` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=processes` + +Main screens and interactions: + +- Plugin settings: + - whether manual process registration is allowed + - health check behavior + - whether operators can stop/restart processes + - log retention preferences +- Process list page: + - status table with name, command, cwd, owner agent, port, uptime, and health + - filters for running/exited/crashed processes + - actions: inspect, stop, restart, tail logs +- Project tab: + - filters the process list to the project's workspaces + - shows which workspace each process belongs to + - groups processes by project workspace +- Process detail page: + - process metadata + - live log tail + - health check history + - links to associated issue or run +- Agent tab: + - shows processes started by or assigned to that agent + +Core workflows: + +- An agent starts a dev server; the plugin detects and tracks it. +- Board opens a project and immediately sees the processes attached to that project's workspace. +- Board sees a crashed process on the dashboard and restarts it from the plugin page. +- Board attaches process logs to an issue when debugging a failure. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `project` and `agent` +- `projects.read` +- `project.workspaces.read` +- `plugin.state.read` +- `plugin.state.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles process management (register, list, terminate, restart, read logs, health probes) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` + +## Stripe Revenue Tracking + +Package idea: `@paperclip/plugin-stripe` + +This plugin pulls Stripe revenue and subscription data into Paperclip. It is useful for: + +- showing MRR and churn next to company goals +- tracking trials, conversions, and failed payments +- letting the board connect revenue movement to ongoing work +- enabling future financial dashboards beyond token costs + +### UX + +- Settings page: `/settings/plugins/stripe` +- Main page: `/:companyPrefix/plugins/stripe` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional company/goal metric tabs if those surfaces exist later + +Main screens and interactions: + +- Plugin settings: + - Stripe secret key secret ref + - account selection if needed + - metric definitions such as MRR treatment and trial handling + - sync interval + - webhook signing secret ref +- Dashboard widgets: + - MRR card + - active subscriptions + - trial-to-paid conversion + - failed payment alerts +- Stripe overview page: + - time series charts + - recent customer/subscription events + - webhook health + - sync history + - action: create issue from billing anomaly + +Core workflows: + +- Board enables the plugin and connects a Stripe account. +- Webhooks and scheduled reconciliation keep plugin state current. +- Revenue widgets appear on the main dashboard and can be linked to company goals. +- Failed payment spikes or churn events can generate Paperclip issues for follow-up. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- `metrics.write` +- optional `issues.create` +- `activity.log.write` + +Important constraint: + +- Stripe data should stay additive to Paperclip core +- it should not leak into core budgeting logic, which is specifically about model/token spend in V1 + +## Specific Patterns From OpenCode Worth Adopting + +## Adopt + +- separate SDK package from runtime loader +- deterministic load order and precedence +- very small authoring API +- typed schemas for plugin inputs/config/tools +- tools as a first-class plugin extension point (namespaced, not override-by-collision) +- internal extensions using the same registration shapes as external ones when reasonable +- plugin load errors isolated from host startup when possible +- explicit community-facing plugin docs and example templates +- test harness and starter template for low authoring friction +- hot plugin lifecycle without server restart (enabled by out-of-process workers) +- formal SDK versioning with multi-version host support + +## Adapt, not copy + +- local path loading +- dependency auto-install +- hook mutation model +- built-in override behavior +- broad runtime context objects + +## Avoid + +- project-local arbitrary code loading +- implicit trust of npm packages at startup +- plugins overriding core invariants +- unsandboxed in-process execution as the default extension model + +## Suggested Rollout Plan + +## Phase 0: Harden the seams that already exist + +- formalize adapter/storage/secret/run-log registries as "platform modules" +- remove ad-hoc fallback behavior where possible +- document stable registration contracts + +## Phase 1: Add connector plugins first + +This is the highest-value, lowest-risk plugin category. + +Build: + +- plugin manifest +- global install/update lifecycle +- global plugin config and optional company-mapping storage +- secret ref access +- typed domain event subscription +- scheduled jobs +- webhook endpoints +- activity logging helpers +- plugin UI bundle loading, host bridge, `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- auto-generated settings form from `instanceConfigSchema` +- bridge error propagation (`PluginBridgeError`) +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering (server-side, per-subscription) +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- uninstall with data retention grace period +- `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase would immediately cover: + +- Linear +- GitHub +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- child process/server tracking + +Workspace plugins do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2: Consider richer UI and plugin packaging + +Only after Phase 1 is stable: + +- iframe-based isolation for untrusted third-party plugin UI bundles +- signed/verified plugin packages +- plugin marketplace +- optional custom plugin storage backends or migrations + +## Recommended Architecture Decision + +If I had to collapse this report into one architectural decision, it would be: + +Paperclip should not implement "an OpenCode-style generic in-process hook system." +Paperclip should implement "a plugin platform with multiple trust tiers": + +- trusted platform modules for low-level runtime integration +- typed out-of-process plugins for instance-wide integrations and automation +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- plugin-to-plugin events for cross-plugin coordination +- auto-generated settings UI from config schema +- core-owned invariants that plugins can observe and act around, but not replace +- plugin observability, graceful lifecycle management, and a test harness for low authoring friction +- hot plugin lifecycle — no server restart for install, uninstall, upgrade, or config changes +- SDK versioning with multi-version host support and clear deprecation policy + +That gets the upside of `opencode`'s extensibility without importing the wrong threat model. + +## Concrete Next Steps I Would Take In Paperclip + +1. Write a short extension architecture RFC that formalizes the distinction between `platform modules` and `plugins`. +2. Introduce a small plugin manifest type in `packages/shared` and a `plugins` install/config section in the instance config. +3. Build a typed domain event bus around existing activity/live-event patterns, with server-side event filtering and a `plugin.*` namespace for cross-plugin events. Keep core invariants non-hookable. +4. Implement plugin MVP: global install/config, secret refs, jobs, webhooks, plugin UI bundles, extension slots, auto-generated settings forms, bridge error propagation. +5. Add agent tool contributions — plugins register namespaced tools that agents can call during runs. +6. Add plugin observability: structured logging via `ctx.logger`, health dashboard, internal health events. +7. Add graceful shutdown policy and uninstall data lifecycle with retention grace period. +8. Ship `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template. +9. Implement hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart. +10. Define SDK versioning policy — semver, multi-version host support, deprecation timeline, migration guides, published compatibility matrix. +11. Build workspace plugins (file browser, terminal, git, process tracking) that resolve workspace paths from the host and handle OS-level operations directly. diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 373c5d48..82006606 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -10,5 +10,9 @@ services: PAPERCLIP_HOME: "/paperclip" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + PAPERCLIP_DEPLOYMENT_MODE: "authenticated" + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" volumes: - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" diff --git a/docker-compose.yml b/docker-compose.yml index d3cdc6ad..94f4291f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ services: POSTGRES_USER: paperclip POSTGRES_PASSWORD: paperclip POSTGRES_DB: paperclip + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"] + interval: 2s + timeout: 5s + retries: 30 ports: - "5432:5432" volumes: @@ -18,8 +23,16 @@ services: DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip PORT: "3100" SERVE_UI: "true" + PAPERCLIP_DEPLOYMENT_MODE: "authenticated" + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" + volumes: + - paperclip-data:/paperclip depends_on: - - db + db: + condition: service_healthy volumes: pgdata: + paperclip-data: diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 254689a2..3b80f288 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory. +For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use: + +```sh +pnpm paperclipai agent local-cli claudecoder --company-id +``` + +This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test Use the "Test Environment" button in the UI to validate the adapter config. It checks: diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index d87172f8..60725a49 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: + +```sh +pnpm paperclipai agent local-cli codexcoder --company-id +``` + +This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. + ## Environment Test The environment test checks: diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 0d3ccabf..4237f87f 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | +| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | @@ -52,7 +54,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local` or `codex_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/docs/api/agents.md b/docs/api/agents.md index 371d2563..143cbc5f 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org Returns the full organizational tree for the company. +## List Adapter Models + +``` +GET /api/companies/{companyId}/adapters/{adapterType}/models +``` + +Returns selectable models for an adapter type. + +- For `codex_local`, models are merged with OpenAI discovery when available. +- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format. +- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty. + ## Config Revisions ``` diff --git a/docs/deploy/local-development.md b/docs/deploy/local-development.md index b664e13a..874477c1 100644 --- a/docs/deploy/local-development.md +++ b/docs/deploy/local-development.md @@ -48,12 +48,20 @@ pnpm dev --tailscale-auth This binds the server to `0.0.0.0` for private-network access. +Alias: + +```sh +pnpm dev --authenticated-private +``` + Allow additional private hostnames: ```sh pnpm paperclipai allowed-hostname dotta-macbook-pro ``` +For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access). + ## Health Checks ```sh diff --git a/docs/deploy/tailscale-private-access.md b/docs/deploy/tailscale-private-access.md new file mode 100644 index 00000000..1e0d2467 --- /dev/null +++ b/docs/deploy/tailscale-private-access.md @@ -0,0 +1,77 @@ +--- +title: Tailscale Private Access +summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices +--- + +Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`. + +## 1. Start Paperclip in private authenticated mode + +```sh +pnpm dev --tailscale-auth +``` + +This configures: + +- `PAPERCLIP_DEPLOYMENT_MODE=authenticated` +- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private` +- `PAPERCLIP_AUTH_BASE_URL_MODE=auto` +- `HOST=0.0.0.0` (bind on all interfaces) + +Equivalent flag: + +```sh +pnpm dev --authenticated-private +``` + +## 2. Find your reachable Tailscale address + +From the machine running Paperclip: + +```sh +tailscale ip -4 +``` + +You can also use your Tailscale MagicDNS hostname (for example `my-macbook.tailnet.ts.net`). + +## 3. Open Paperclip from another device + +Use the Tailscale IP or MagicDNS host with the Paperclip port: + +```txt +http://:3100 +``` + +Example: + +```txt +http://my-macbook.tailnet.ts.net:3100 +``` + +## 4. Allow custom private hostnames when needed + +If you access Paperclip with a custom private hostname, add it to the allowlist: + +```sh +pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net +``` + +## 5. Verify the server is reachable + +From a remote Tailscale-connected device: + +```sh +curl http://:3100/api/health +``` + +Expected result: + +```json +{"status":"ok"} +``` + +## Troubleshooting + +- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`. +- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode). +- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable. diff --git a/docs/docs.json b/docs/docs.json index 1f3b8574..96b9f696 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -73,6 +73,7 @@ "pages": [ "deploy/overview", "deploy/local-development", + "deploy/tailscale-private-access", "deploy/docker", "deploy/deployment-modes", "deploy/database", diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 4154ee3a..453b967f 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires: - **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.) - **Capabilities** — short description of what this agent does +Common adapter choices: +- `claude_local` / `codex_local` / `opencode_local` for local coding agents +- `openclaw` / `http` for webhook-based external agents +- `process` for generic local command execution + +For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). +Paperclip validates the selected model against live `opencode models` output. + ## Agent Hiring via Governance Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject. diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 9a852f32..bf8d1496 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -33,12 +33,16 @@ To spin up OpenClaw in Docker and print a host-browser dashboard URL in one comm pnpm smoke:openclaw-docker-ui ``` +Default behavior is zero-flag: you can run the command as-is with no pairing-related env vars. + What this command does: - clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker` - builds `openclaw:local` (unless `OPENCLAW_BUILD=0`) -- writes `~/.openclaw/openclaw.json` and Docker `.env` +- writes isolated smoke config under `~/.openclaw-paperclip-smoke/openclaw.json` and Docker `.env` +- pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback) - starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override) +- probes and prints a Paperclip host URL that is reachable from inside OpenClaw Docker - waits for health and prints: - `http://127.0.0.1:18789/#token=...` - disables Control UI device pairing by default for local smoke ergonomics @@ -53,6 +57,12 @@ Environment knobs: - `OPENCLAW_OPEN_BROWSER=1` to auto-open the URL on macOS - `OPENCLAW_DISABLE_DEVICE_AUTH=1` (default) disables Control UI device pairing for local smoke - `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands) +- `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`) +- `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`) +- `OPENCLAW_CONFIG_DIR` (default `~/.openclaw-paperclip-smoke`) +- `OPENCLAW_RESET_STATE=1` (default) resets smoke agent state on each run to avoid stale auth/session drift +- `PAPERCLIP_HOST_PORT` (default `3100`) +- `PAPERCLIP_HOST_FROM_CONTAINER` (default `host.docker.internal`) ### Authenticated mode @@ -67,6 +77,15 @@ PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join ### Network topology tips - Local same-host smoke: default callback uses `http://127.0.0.1:/webhook`. +- Inside OpenClaw Docker, `127.0.0.1` points to the container itself, not your host Paperclip server. +- For invite/onboarding URLs consumed by OpenClaw in Docker, use the script-printed Paperclip URL (typically `http://host.docker.internal:3100`). +- If Paperclip rejects the container-visible host with a hostname error, allow it from host: + +```bash +pnpm paperclipai allowed-hostname host.docker.internal +``` + +Then restart Paperclip and rerun the smoke script. - Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain). - Authenticated/private mode: ensure hostnames are in the allowed list when required: diff --git a/package.json b/package.json index b0ac83d5..45c02b8b 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/dev-runner.mjs dev", + "dev": "node scripts/dev-runner.mjs watch", "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch", + "dev:once": "node scripts/dev-runner.mjs dev", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", "build": "pnpm -r build", @@ -23,7 +24,8 @@ "check:tokens": "node scripts/check-forbidden-tokens.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", - "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh" + "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", + "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh" }, "devDependencies": { "@changesets/cli": "^2.30.0", diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 8a9411af..118eb895 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -30,6 +30,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index cfc1bc8d..83605307 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -13,6 +13,8 @@ export type { AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, + HireApprovedPayload, + HireApprovedHookResult, ServerAdapterModule, TranscriptEntry, StdoutLineParser, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 1c8b76bd..76efba86 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -15,6 +15,14 @@ interface RunningProcess { graceSec: number; } +type ChildProcessWithEvents = ChildProcess & { + on(event: "error", listener: (err: Error) => void): ChildProcess; + on( + event: "close", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ChildProcess; +}; + export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; @@ -217,7 +225,7 @@ export async function runChildProcess( env: mergedEnv, shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }); + }) as ChildProcessWithEvents; if (opts.stdin != null && child.stdin) { child.stdin.write(opts.stdin); @@ -244,7 +252,7 @@ export async function runChildProcess( }, opts.timeoutSec * 1000) : null; - child.stdout?.on("data", (chunk) => { + child.stdout?.on("data", (chunk: unknown) => { const text = String(chunk); stdout = appendWithCap(stdout, text); logChain = logChain @@ -252,7 +260,7 @@ export async function runChildProcess( .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); }); - child.stderr?.on("data", (chunk) => { + child.stderr?.on("data", (chunk: unknown) => { const text = String(chunk); stderr = appendWithCap(stderr, text); logChain = logChain @@ -260,7 +268,7 @@ export async function runChildProcess( .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); }); - child.on("error", (err) => { + child.on("error", (err: Error) => { if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); const errno = (err as NodeJS.ErrnoException).code; @@ -272,7 +280,7 @@ export async function runChildProcess( reject(new Error(msg)); }); - child.on("close", (code, signal) => { + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); void logChain.finally(() => { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 95143b51..bf9b7748 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -119,6 +119,27 @@ export interface AdapterEnvironmentTestContext { }; } +/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */ +export interface HireApprovedPayload { + companyId: string; + agentId: string; + agentName: string; + adapterType: string; + /** "join_request" | "approval" */ + source: "join_request" | "approval"; + sourceId: string; + approvedAt: string; + /** Canonical operator-facing message for cloud adapters to show the user. */ + message: string; +} + +/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */ +export interface HireApprovedHookResult { + ok: boolean; + error?: string; + detail?: Record; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -128,6 +149,14 @@ export interface ServerAdapterModule { models?: AdapterModel[]; listModels?: () => Promise; agentConfigurationDoc?: string; + /** + * Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval). + * adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL. + */ + onHireApproved?: ( + payload: HireApprovedPayload, + adapterConfig: Record, + ) => Promise; } // --------------------------------------------------------------------------- @@ -135,7 +164,7 @@ export interface ServerAdapterModule { // --------------------------------------------------------------------------- export type TranscriptEntry = - | { kind: "assistant"; ts: string; text: string } + | { kind: "assistant"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } | { kind: "tool_call"; ts: string; name: string; input: unknown } diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index faa16b64..c999013d 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 481c305d..f8b59bad 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -3,6 +3,8 @@ export const label = "Claude Code (local)"; export const models = [ { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index 9fc9b581..e6853aa7 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 662bc8a7..5845fba8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -56,7 +56,7 @@ Use when: - You want structured stream output in run logs via --output-format stream-json Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Cursor Agent CLI is not installed on the machine diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 2f355cfe..90314411 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md new file mode 100644 index 00000000..ba3edde2 --- /dev/null +++ b/packages/adapters/openclaw-gateway/README.md @@ -0,0 +1,72 @@ +# OpenClaw Gateway Adapter + +This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol. + +## Transport + +This adapter always uses WebSocket gateway transport. + +- URL must be `ws://` or `wss://` +- Connect flow follows gateway protocol: +1. receive `connect.challenge` +2. send `req connect` (protocol/client/auth/device payload) +3. send `req agent` +4. wait for completion via `req agent.wait` +5. stream `event agent` frames into Paperclip logs/transcript parsing + +## Auth Modes + +Gateway credentials can be provided in any of these ways: + +- `authToken` / `token` in adapter config +- `headers.x-openclaw-token` +- `headers.x-openclaw-auth` (legacy) +- `password` (shared password mode) + +When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer `. + +## Device Auth + +By default the adapter sends a signed `device` payload in `connect` params. + +- set `disableDeviceAuth=true` to omit device signing +- set `devicePrivateKeyPem` to pin a stable signing key +- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. + +## Session Strategy + +The adapter supports the same session routing model as HTTP OpenClaw mode: + +- `sessionKeyStrategy=issue|fixed|run` +- `sessionKey` is used when strategy is `fixed` + +Resolved session key is sent as `agent.sessionKey`. + +## Payload Mapping + +The agent request is built as: + +- required fields: + - `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix) + - `idempotencyKey` (Paperclip `runId`) + - `sessionKey` (resolved strategy) +- optional additions: + - all `payloadTemplate` fields merged in + - `agentId` from config if set and not already in template + +## Timeouts + +- `timeoutSec` controls adapter-level request budget +- `waitTimeoutMs` controls `agent.wait.timeoutMs` + +If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`. + +## Log Format + +Structured gateway event logs use: + +- `[openclaw-gateway] ...` for lifecycle/system logs +- `[openclaw-gateway:event] run= stream= data=` for `event agent` frames + +UI/CLI parsers consume these lines to render transcript updates. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md new file mode 100644 index 00000000..66ff2a4a --- /dev/null +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -0,0 +1,109 @@ +# OpenClaw Gateway Onboarding and Test Plan + +## Scope +This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only. + +- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching) +- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`) + +## Requirements +1. OpenClaw test image must be stock/clean every run. +2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed). +3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`. +4. Invite/access flow must be secure: +- invite prompt endpoint is board-permission protected +- CEO agent is allowed to invoke the invite prompt endpoint for their own company +5. E2E pass criteria must include the 3 functional task cases. + +## Current Product Flow +1. Board/CEO opens company settings. +2. Click `Generate OpenClaw Invite Prompt`. +3. Paste generated prompt into OpenClaw chat. +4. OpenClaw submits invite acceptance with: +- `adapterType: "openclaw_gateway"` +- `agentDefaultsPayload.url: ws://... | wss://...` +- `agentDefaultsPayload.headers["x-openclaw-token"]` +5. Board approves join request. +6. OpenClaw claims API key and installs/uses Paperclip skill. +7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key. + +## Technical Contract (Gateway) +`agentDefaultsPayload` minimum: +```json +{ + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" } +} +``` + +Recommended fields: +```json +{ + "paperclipApiUrl": "http://host.docker.internal:3100", + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] +} +``` + +Security/pairing defaults: +- `disableDeviceAuth`: default false +- `devicePrivateKeyPem`: generated during join if missing + +## Codex Automation Workflow + +### 0) Reset and boot +```bash +OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker +if [ -d "$OPENCLAW_DOCKER_DIR" ]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true +fi + +docker image rm openclaw:local || true +OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh +``` + +### 1) Start Paperclip +```bash +pnpm dev --tailscale-auth +curl -fsS http://127.0.0.1:3100/api/health +``` + +### 2) Invite + join + approval +- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt` +- paste prompt to OpenClaw +- approve join request +- assert created agent: + - `adapterType == openclaw_gateway` + - token header exists and length >= 16 + - `devicePrivateKeyPem` exists + +### 3) Pairing stabilization +- if first run returns `pairing required`, approve pending device in OpenClaw +- rerun task and confirm success +- assert later runs do not require re-pairing for same agent + +### 4) Functional E2E assertions +1. Task assigned to OpenClaw is completed and closed. +2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat). +3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task. + +## Manual Smoke Checklist +Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook. + +## Regression Gates +Required before merge: +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +If full suite is too heavy locally, run at least: +```bash +pnpm --filter @paperclipai/server test:run -- openclaw-gateway +pnpm --filter @paperclipai/server typecheck +pnpm --filter @paperclipai/ui typecheck +pnpm --filter paperclipai typecheck +``` diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json new file mode 100644 index 00000000..0999b220 --- /dev/null +++ b/packages/adapters/openclaw-gateway/package.json @@ -0,0 +1,52 @@ +{ + "name": "@paperclipai/adapter-openclaw-gateway", + "version": "0.2.7", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/ws": "^8.18.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/openclaw-gateway/src/cli/format-event.ts b/packages/adapters/openclaw-gateway/src/cli/format-event.ts new file mode 100644 index 00000000..55814317 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/format-event.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + if (!debug) { + console.log(line); + return; + } + + if (line.startsWith("[openclaw-gateway:event]")) { + console.log(pc.cyan(line)); + return; + } + + if (line.startsWith("[openclaw-gateway]")) { + console.log(pc.blue(line)); + return; + } + + console.log(pc.gray(line)); +} diff --git a/packages/adapters/openclaw-gateway/src/cli/index.ts b/packages/adapters/openclaw-gateway/src/cli/index.ts new file mode 100644 index 00000000..9c621bcb --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenClawGatewayStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts new file mode 100644 index 00000000..2af13f99 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -0,0 +1,42 @@ +export const type = "openclaw_gateway"; +export const label = "OpenClaw Gateway"; + +export const models: { id: string; label: string }[] = []; + +export const agentConfigurationDoc = `# openclaw_gateway agent configuration + +Adapter: openclaw_gateway + +Use when: +- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol. +- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*. + +Don't use when: +- You only expose OpenClaw HTTP endpoints. +- Your deployment does not permit outbound WebSocket access from the Paperclip server. + +Core fields: +- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://) +- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth +- authToken (string, optional): shared gateway token override +- password (string, optional): gateway shared password, if configured + +Gateway connect identity fields: +- clientId (string, optional): gateway client id (default gateway-client) +- clientMode (string, optional): gateway client mode (default backend) +- clientVersion (string, optional): client version string +- role (string, optional): gateway role (default operator) +- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"]) +- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false) + +Request behavior fields: +- payloadTemplate (object, optional): additional fields merged into gateway agent params +- timeoutSec (number, optional): adapter timeout in seconds (default 120) +- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) +- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text + +Session routing fields: +- sessionKeyStrategy (string, optional): issue (default), fixed, or run +- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) +`; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts new file mode 100644 index 00000000..c8de510d --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -0,0 +1,1278 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import crypto, { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +type SessionKeyStrategy = "fixed" | "issue" | "run"; + +type WakePayload = { + runId: string; + agentId: string; + companyId: string; + taskId: string | null; + issueId: string | null; + wakeReason: string | null; + wakeCommentId: string | null; + approvalId: string | null; + approvalStatus: string | null; + issueIds: string[]; +}; + +type GatewayDeviceIdentity = { + deviceId: string; + publicKeyRawBase64Url: string; + privateKeyPem: string; + source: "configured" | "ephemeral"; +}; + +type GatewayRequestFrame = { + type: "req"; + id: string; + method: string; + params?: unknown; +}; + +type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + }; +}; + +type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + expectFinal: boolean; + timer: ReturnType | null; +}; + +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + +type GatewayClientOptions = { + url: string; + headers: Record; + onEvent: (frame: GatewayEventFrame) => Promise | void; + onLog: AdapterExecutionContext["onLog"]; +}; + +type GatewayClientRequestOptions = { + timeoutMs: number; + expectFinal?: boolean; +}; + +const PROTOCOL_VERSION = 3; +const DEFAULT_SCOPES = ["operator.admin"]; +const DEFAULT_CLIENT_ID = "gateway-client"; +const DEFAULT_CLIENT_MODE = "backend"; +const DEFAULT_CLIENT_VERSION = "paperclip"; +const DEFAULT_ROLE = "operator"; + +const SENSITIVE_LOG_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function parseOptionalPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(1, Math.floor(value)); + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed)); + } + return null; +} + +function parseBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return fallback; +} + +function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { + const normalized = asString(value, "issue").trim().toLowerCase(); + if (normalized === "fixed" || normalized === "run") return normalized; + return "issue"; +} + +function resolveSessionKey(input: { + strategy: SessionKeyStrategy; + configuredSessionKey: string | null; + runId: string; + issueId: string | null; +}): string { + const fallback = input.configuredSessionKey ?? "paperclip"; + if (input.strategy === "run") return `paperclip:run:${input.runId}`; + if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; + return fallback; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function normalizeScopes(value: unknown): string[] { + const parsed = toStringArray(value); + return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; +} + +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function headerMapHasIgnoreCase(headers: Record, key: string): boolean { + return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); +} + +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + +function toAuthorizationHeaderValue(rawToken: string): string { + const trimmed = rawToken.trim(); + if (!trimmed) return trimmed; + return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function isSensitiveLogKey(key: string): boolean { + return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); +} + +function sha256Prefix(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function redactSecretForLog(value: string): string { + return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; +} + +function truncateForLog(value: string, maxChars = 320): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; +} + +function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { + const currentKey = keyPath[keyPath.length - 1] ?? ""; + if (typeof value === "string") { + if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); + return truncateForLog(value); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return value; + } + if (Array.isArray(value)) { + if (depth >= 6) return "[array-truncated]"; + const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); + if (value.length > 20) out.push(`[+${value.length - 20} more items]`); + return out; + } + if (typeof value === "object") { + if (depth >= 6) return "[object-truncated]"; + const entries = Object.entries(value as Record); + const out: Record = {}; + for (const [key, entry] of entries.slice(0, 80)) { + out[key] = redactForLog(entry, [...keyPath, key], depth + 1); + } + if (entries.length > 80) { + out.__truncated__ = `+${entries.length - 80} keys`; + } + return out; + } + return String(value); +} + +function stringifyForLog(value: unknown, maxChars: number): string { + const text = JSON.stringify(value); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { + const { runId, agent, context } = ctx; + return { + runId, + agentId: agent.id, + companyId: agent.companyId, + taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), + issueId: nonEmpty(context.issueId), + wakeReason: nonEmpty(context.wakeReason), + wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), + approvalId: nonEmpty(context.approvalId), + approvalStatus: nonEmpty(context.approvalStatus), + issueIds: Array.isArray(context.issueIds) + ? context.issueIds.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : [], + }; +} + +function resolvePaperclipApiUrlOverride(value: unknown): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.toString(); + } catch { + return null; + } +} + +function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { + const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); + const paperclipEnv: Record = { + ...buildPaperclipEnv(ctx.agent), + PAPERCLIP_RUN_ID: ctx.runId, + }; + + if (paperclipApiUrlOverride) { + paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; + } + if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; + if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; + if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; + if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; + if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; + if (wakePayload.issueIds.length > 0) { + paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); + } + + return paperclipEnv; +} + +function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { + const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; + const orderedKeys = [ + "PAPERCLIP_RUN_ID", + "PAPERCLIP_AGENT_ID", + "PAPERCLIP_COMPANY_ID", + "PAPERCLIP_API_URL", + "PAPERCLIP_TASK_ID", + "PAPERCLIP_WAKE_REASON", + "PAPERCLIP_WAKE_COMMENT_ID", + "PAPERCLIP_APPROVAL_ID", + "PAPERCLIP_APPROVAL_STATUS", + "PAPERCLIP_LINKED_ISSUE_IDS", + ]; + + const envLines: string[] = []; + for (const key of orderedKeys) { + const value = paperclipEnv[key]; + if (!value) continue; + envLines.push(`${key}=${value}`); + } + + const issueIdHint = payload.taskId ?? payload.issueId ?? ""; + const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? ""; + + const lines = [ + "Paperclip wake event for a cloud adapter.", + "", + "Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.", + "", + "Set these values in your run context:", + ...envLines, + `PAPERCLIP_API_KEY=`, + "", + `Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`, + "", + `api_base=${apiBaseHint}`, + `task_id=${payload.taskId ?? ""}`, + `issue_id=${payload.issueId ?? ""}`, + `wake_reason=${payload.wakeReason ?? ""}`, + `wake_comment_id=${payload.wakeCommentId ?? ""}`, + `approval_id=${payload.approvalId ?? ""}`, + `approval_status=${payload.approvalStatus ?? ""}`, + `linked_issue_ids=${payload.issueIds.join(",")}`, + "", + "HTTP rules:", + "- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.", + "- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.", + "- Use only /api endpoints listed below.", + "- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.", + "", + "Workflow:", + "1) GET /api/agents/me", + `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, + "3) If issueId exists:", + " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}", + " - GET /api/issues/{issueId}", + " - GET /api/issues/{issueId}/comments", + " - Execute the issue instructions exactly.", + " - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.", + " - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.", + "4) If issueId does not exist:", + " - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked", + " - Pick in_progress first, then todo, then blocked, then execute step 3.", + "", + "Useful endpoints for issue work:", + "- POST /api/issues/{issueId}/comments", + "- PATCH /api/issues/{issueId}", + "- POST /api/companies/{companyId}/issues (when asked to create a new issue)", + "", + "Complete the workflow in this run.", + ]; + return lines.join("\n"); +} + +function appendWakeText(baseText: string, wakeText: string): string { + const trimmedBase = baseText.trim(); + return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; +} + +function normalizeUrl(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { + const key = crypto.createPublicKey(publicKeyPem); + const spki = key.export({ type: "spki", format: "der" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function signDevicePayload(privateKeyPem: string, payload: string): string { + const key = crypto.createPrivateKey(privateKeyPem); + const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key); + return base64UrlEncode(sig); +} + +function buildDeviceAuthPayloadV3(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce: string; + platform?: string | null; + deviceFamily?: string | null; +}): string { + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const platform = params.platform?.trim() ?? ""; + const deviceFamily = params.deviceFamily?.trim() ?? ""; + return [ + "v3", + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + params.nonce, + platform, + deviceFamily, + ].join("|"); +} + +function resolveDeviceIdentity(config: Record): GatewayDeviceIdentity { + const configuredPrivateKey = nonEmpty(config.devicePrivateKeyPem); + if (configuredPrivateKey) { + const privateKey = crypto.createPrivateKey(configuredPrivateKey); + const publicKey = crypto.createPublicKey(privateKey); + const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem: configuredPrivateKey, + source: "configured", + }; + } + + const generated = crypto.generateKeyPairSync("ed25519"); + const publicKeyPem = generated.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKeyPem = generated.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const raw = derivePublicKeyRaw(publicKeyPem); + return { + deviceId: crypto.createHash("sha256").update(raw).digest("hex"), + publicKeyRawBase64Url: base64UrlEncode(raw), + privateKeyPem, + source: "ephemeral", + }; +} + +function isResponseFrame(value: unknown): value is GatewayResponseFrame { + const record = asRecord(value); + return Boolean(record && record.type === "res" && typeof record.id === "string" && typeof record.ok === "boolean"); +} + +function isEventFrame(value: unknown): value is GatewayEventFrame { + const record = asRecord(value); + return Boolean(record && record.type === "event" && typeof record.event === "string"); +} + +class GatewayWsClient { + private ws: WebSocket | null = null; + private pending = new Map(); + private challengePromise: Promise; + private resolveChallenge!: (nonce: string) => void; + private rejectChallenge!: (err: Error) => void; + + constructor(private readonly opts: GatewayClientOptions) { + this.challengePromise = new Promise((resolve, reject) => { + this.resolveChallenge = resolve; + this.rejectChallenge = reject; + }); + } + + async connect( + buildConnectParams: (nonce: string) => Record, + timeoutMs: number, + ): Promise | null> { + this.ws = new WebSocket(this.opts.url, { + headers: this.opts.headers, + maxPayload: 25 * 1024 * 1024, + }); + + const ws = this.ws; + + ws.on("message", (data) => { + this.handleMessage(rawDataToString(data)); + }); + + ws.on("close", (code, reason) => { + const reasonText = rawDataToString(reason); + const err = new Error(`gateway closed (${code}): ${reasonText}`); + this.failPending(err); + this.rejectChallenge(err); + }); + + ws.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + void this.opts.onLog("stderr", `[openclaw-gateway] websocket error: ${message}\n`); + }); + + await withTimeout( + new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`gateway closed before open (${code}): ${rawDataToString(reason)}`)); + }; + const cleanup = () => { + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }), + timeoutMs, + "gateway websocket open timeout", + ); + + const nonce = await withTimeout(this.challengePromise, timeoutMs, "gateway connect challenge timeout"); + const signedConnectParams = buildConnectParams(nonce); + + const hello = await this.request | null>("connect", signedConnectParams, { + timeoutMs, + }); + + return hello; + } + + async request( + method: string, + params: unknown, + opts: GatewayClientRequestOptions, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("gateway not connected"); + } + + const id = randomUUID(); + const frame: GatewayRequestFrame = { + type: "req", + id, + method, + params, + }; + + const payload = JSON.stringify(frame); + const requestPromise = new Promise((resolve, reject) => { + const timer = + opts.timeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new Error(`gateway request timeout (${method})`)); + }, opts.timeoutMs) + : null; + + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + expectFinal: opts.expectFinal === true, + timer, + }); + }); + + this.ws.send(payload); + return requestPromise; + } + + close() { + if (!this.ws) return; + this.ws.close(1000, "paperclip-complete"); + this.ws = null; + } + + private failPending(err: Error) { + for (const [, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(err); + } + this.pending.clear(); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (isEventFrame(parsed)) { + if (parsed.event === "connect.challenge") { + const payload = asRecord(parsed.payload); + const nonce = nonEmpty(payload?.nonce); + if (nonce) { + this.resolveChallenge(nonce); + return; + } + } + void Promise.resolve(this.opts.onEvent(parsed)).catch(() => { + // Ignore event callback failures and keep stream active. + }); + return; + } + + if (!isResponseFrame(parsed)) return; + + const pending = this.pending.get(parsed.id); + if (!pending) return; + + const payload = asRecord(parsed.payload); + const status = nonEmpty(payload?.status)?.toLowerCase(); + if (pending.expectFinal && status === "accepted") { + return; + } + + if (pending.timer) clearTimeout(pending.timer); + this.pending.delete(parsed.id); + + if (parsed.ok) { + pending.resolve(parsed.payload ?? null); + return; + } + + const errorRecord = asRecord(parsed.error); + const message = + nonEmpty(errorRecord?.message) ?? + nonEmpty(errorRecord?.code) ?? + "gateway request failed"; + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); + } +} + +function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = asNumber(record.inputTokens ?? record.input, 0); + const outputTokens = asNumber(record.outputTokens ?? record.output, 0); + const cachedInputTokens = asNumber( + record.cachedInputTokens ?? record.cached_input_tokens ?? record.cacheRead ?? record.cache_read, + 0, + ); + + if (inputTokens <= 0 && outputTokens <= 0 && cachedInputTokens <= 0) { + return undefined; + } + + return { + inputTokens, + outputTokens, + ...(cachedInputTokens > 0 ? { cachedInputTokens } : {}), + }; +} + +function extractResultText(value: unknown): string | null { + const record = asRecord(value); + if (!record) return null; + + const payloads = Array.isArray(record.payloads) ? record.payloads : []; + const texts = payloads + .map((entry) => { + const payload = asRecord(entry); + return nonEmpty(payload?.text); + }) + .filter((entry): entry is string => Boolean(entry)); + + if (texts.length > 0) return texts.join("\n\n"); + return nonEmpty(record.text) ?? nonEmpty(record.summary) ?? null; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const urlValue = asString(ctx.config.url, "").trim(); + if (!urlValue) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "OpenClaw gateway adapter missing url", + errorCode: "openclaw_gateway_url_missing", + }; + } + + const parsedUrl = normalizeUrl(urlValue); + if (!parsedUrl) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Invalid gateway URL: ${urlValue}`, + errorCode: "openclaw_gateway_url_invalid", + }; + } + + if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unsupported gateway URL protocol: ${parsedUrl.protocol}`, + errorCode: "openclaw_gateway_url_protocol", + }; + } + + const timeoutSec = Math.max(0, Math.floor(asNumber(ctx.config.timeoutSec, 120))); + const timeoutMs = timeoutSec > 0 ? timeoutSec * 1000 : 0; + const connectTimeoutMs = timeoutMs > 0 ? Math.min(timeoutMs, 15_000) : 10_000; + const waitTimeoutMs = parseOptionalPositiveInteger(ctx.config.waitTimeoutMs) ?? (timeoutMs > 0 ? timeoutMs : 30_000); + + const payloadTemplate = parseObject(ctx.config.payloadTemplate); + const transportHint = nonEmpty(ctx.config.streamTransport) ?? nonEmpty(ctx.config.transport); + + const headers = toStringRecord(ctx.config.headers); + const authToken = resolveAuthToken(parseObject(ctx.config), headers); + const password = nonEmpty(ctx.config.password); + const deviceToken = nonEmpty(ctx.config.deviceToken); + + if (authToken && !headerMapHasIgnoreCase(headers, "authorization")) { + headers.authorization = toAuthorizationHeaderValue(authToken); + } + + const clientId = nonEmpty(ctx.config.clientId) ?? DEFAULT_CLIENT_ID; + const clientMode = nonEmpty(ctx.config.clientMode) ?? DEFAULT_CLIENT_MODE; + const clientVersion = nonEmpty(ctx.config.clientVersion) ?? DEFAULT_CLIENT_VERSION; + const role = nonEmpty(ctx.config.role) ?? DEFAULT_ROLE; + const scopes = normalizeScopes(ctx.config.scopes); + const deviceFamily = nonEmpty(ctx.config.deviceFamily); + const disableDeviceAuth = parseBoolean(ctx.config.disableDeviceAuth, false); + + const wakePayload = buildWakePayload(ctx); + const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); + const wakeText = buildWakeText(wakePayload, paperclipEnv); + + const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); + const configuredSessionKey = nonEmpty(ctx.config.sessionKey); + const sessionKey = resolveSessionKey({ + strategy: sessionKeyStrategy, + configuredSessionKey, + runId: ctx.runId, + issueId: wakePayload.issueId, + }); + + const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text); + const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText; + + const agentParams: Record = { + ...payloadTemplate, + message, + sessionKey, + idempotencyKey: ctx.runId, + }; + delete agentParams.text; + + const configuredAgentId = nonEmpty(ctx.config.agentId); + if (configuredAgentId && !nonEmpty(agentParams.agentId)) { + agentParams.agentId = configuredAgentId; + } + + if (typeof agentParams.timeout !== "number") { + agentParams.timeout = waitTimeoutMs; + } + + if (ctx.onMeta) { + await ctx.onMeta({ + adapterType: "openclaw_gateway", + command: "gateway", + commandArgs: ["ws", parsedUrl.toString(), "agent"], + context: ctx.context, + }); + } + + const outboundHeaderKeys = Object.keys(headers).sort(); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, + ); + await ctx.onLog( + "stdout", + `[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`, + ); + await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); + if (transportHint) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`, + ); + } + if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) { + await ctx.onLog( + "stdout", + "[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n", + ); + } + + const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true); + let autoPairAttempted = false; + let latestResultPayload: unknown = null; + + while (true) { + const trackedRunIds = new Set([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; + + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; + } + + const payload = asRecord(frame.payload); + if (!payload) return; + + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; + await ctx.onLog( + "stdout", + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, + ); + + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; + } + + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } + + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, + }); + + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } + + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); + + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + const detailedMessage = pairingRequired + ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` + : message; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); + + return { + exitCode: 1, + signal: null, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), + }; + } finally { + client.close(); + } + } +} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw-gateway/src/server/index.ts similarity index 50% rename from packages/adapters/openclaw/src/server/index.ts rename to packages/adapters/openclaw-gateway/src/server/index.ts index b44c258b..04036438 100644 --- a/packages/adapters/openclaw/src/server/index.ts +++ b/packages/adapters/openclaw-gateway/src/server/index.ts @@ -1,3 +1,2 @@ export { execute } from "./execute.js"; export { testEnvironment } from "./test.js"; -export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/openclaw-gateway/src/server/test.ts b/packages/adapters/openclaw-gateway/src/server/test.ts new file mode 100644 index 00000000..af4c74d1 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/server/test.ts @@ -0,0 +1,317 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { randomUUID } from "node:crypto"; +import { WebSocket } from "ws"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function toStringRecord(value: unknown): Record { + const parsed = parseObject(value); + const out: Record = {}; + for (const [key, entry] of Object.entries(parsed)) { + if (typeof entry === "string") out[key] = entry; + } + return out; +} + +function toStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function headerMapGetIgnoreCase(headers: Record, key: string): string | null { + const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); + return match ? match[1] : null; +} + +function tokenFromAuthHeader(rawHeader: string | null): string | null { + if (!rawHeader) return null; + const trimmed = rawHeader.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^bearer\s+(.+)$/i); + return match ? nonEmpty(match[1]) : trimmed; +} + +function resolveAuthToken(config: Record, headers: Record): string | null { + const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token); + if (explicit) return explicit; + + const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token"); + if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader); + + const authHeader = + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + headerMapGetIgnoreCase(headers, "authorization"); + return tokenFromAuthHeader(authHeader); +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function rawDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) { + return Buffer.concat( + data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))), + ).toString("utf8"); + } + return String(data ?? ""); +} + +async function probeGateway(input: { + url: string; + headers: Record; + authToken: string | null; + role: string; + scopes: string[]; + timeoutMs: number; +}): Promise<"ok" | "challenge_only" | "failed"> { + return await new Promise((resolve) => { + const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 }); + const timeout = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + resolve("failed"); + }, input.timeoutMs); + + let completed = false; + + const finish = (status: "ok" | "challenge_only" | "failed") => { + if (completed) return; + completed = true; + clearTimeout(timeout); + try { + ws.close(); + } catch { + // ignore + } + resolve(status); + }; + + ws.on("message", (raw) => { + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(raw)); + } catch { + return; + } + const event = asRecord(parsed); + if (event?.type === "event" && event.event === "connect.challenge") { + const nonce = nonEmpty(asRecord(event.payload)?.nonce); + if (!nonce) { + finish("failed"); + return; + } + + const connectId = randomUUID(); + ws.send( + JSON.stringify({ + type: "req", + id: connectId, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: "gateway-client", + version: "paperclip-probe", + platform: process.platform, + mode: "probe", + }, + role: input.role, + scopes: input.scopes, + ...(input.authToken + ? { + auth: { + token: input.authToken, + }, + } + : {}), + }, + }), + ); + return; + } + + if (event?.type === "res") { + if (event.ok === true) { + finish("ok"); + } else { + finish("challenge_only"); + } + } + }); + + ws.on("error", () => { + finish("failed"); + }); + + ws.on("close", () => { + if (!completed) finish("failed"); + }); + }); +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const urlValue = asString(config.url, "").trim(); + + if (!urlValue) { + checks.push({ + code: "openclaw_gateway_url_missing", + level: "error", + message: "OpenClaw gateway adapter requires a WebSocket URL.", + hint: "Set adapterConfig.url to ws://host:port (or wss://).", + }); + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + + let url: URL | null = null; + try { + url = new URL(urlValue); + } catch { + checks.push({ + code: "openclaw_gateway_url_invalid", + level: "error", + message: `Invalid URL: ${urlValue}`, + }); + } + + if (url && url.protocol !== "ws:" && url.protocol !== "wss:") { + checks.push({ + code: "openclaw_gateway_url_protocol_invalid", + level: "error", + message: `Unsupported URL protocol: ${url.protocol}`, + hint: "Use ws:// or wss://.", + }); + } + + if (url) { + checks.push({ + code: "openclaw_gateway_url_valid", + level: "info", + message: `Configured gateway URL: ${url.toString()}`, + }); + + if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) { + checks.push({ + code: "openclaw_gateway_plaintext_remote_ws", + level: "warn", + message: "Gateway URL uses plaintext ws:// on a non-loopback host.", + hint: "Prefer wss:// for remote gateways.", + }); + } + } + + const headers = toStringRecord(config.headers); + const authToken = resolveAuthToken(config, headers); + const password = nonEmpty(config.password); + const role = nonEmpty(config.role) ?? "operator"; + const scopes = toStringArray(config.scopes); + + if (authToken || password) { + checks.push({ + code: "openclaw_gateway_auth_present", + level: "info", + message: "Gateway credentials are configured.", + }); + } else { + checks.push({ + code: "openclaw_gateway_auth_missing", + level: "warn", + message: "No gateway credentials detected in adapter config.", + hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.", + }); + } + + if (url && (url.protocol === "ws:" || url.protocol === "wss:")) { + try { + const probeResult = await probeGateway({ + url: url.toString(), + headers, + authToken, + role, + scopes: scopes.length > 0 ? scopes : ["operator.admin"], + timeoutMs: 3_000, + }); + + if (probeResult === "ok") { + checks.push({ + code: "openclaw_gateway_probe_ok", + level: "info", + message: "Gateway connect probe succeeded.", + }); + } else if (probeResult === "challenge_only") { + checks.push({ + code: "openclaw_gateway_probe_challenge_only", + level: "warn", + message: "Gateway challenge was received, but connect probe was rejected.", + hint: "Check gateway credentials, scopes, role, and device-auth requirements.", + }); + } else { + checks.push({ + code: "openclaw_gateway_probe_failed", + level: "warn", + message: "Gateway probe failed.", + hint: "Verify network reachability and gateway URL from the Paperclip server host.", + }); + } + } catch (err) { + checks.push({ + code: "openclaw_gateway_probe_error", + level: "warn", + message: err instanceof Error ? err.message : "Gateway probe failed", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/openclaw-gateway/src/shared/stream.ts b/packages/adapters/openclaw-gateway/src/shared/stream.ts new file mode 100644 index 00000000..860fc367 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/shared/stream.ts @@ -0,0 +1,16 @@ +export function normalizeOpenClawGatewayStreamLine(rawLine: string): { + stream: "stdout" | "stderr" | null; + line: string; +} { + const trimmed = rawLine.trim(); + if (!trimmed) return { stream: null, line: "" }; + + const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); + if (!prefixed) { + return { stream: null, line: trimmed }; + } + + const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; + const line = (prefixed[2] ?? "").trim(); + return { stream, line }; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts new file mode 100644 index 00000000..6a749f84 --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -0,0 +1,12 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.timeoutSec = 120; + ac.waitTimeoutMs = 120000; + ac.sessionKeyStrategy = "issue"; + ac.role = "operator"; + ac.scopes = ["operator.admin"]; + return ac; +} diff --git a/packages/adapters/openclaw-gateway/src/ui/index.ts b/packages/adapters/openclaw-gateway/src/ui/index.ts new file mode 100644 index 00000000..c2ec0bcf --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js"; +export { buildOpenClawGatewayConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts new file mode 100644 index 00000000..c8cb48ae --- /dev/null +++ b/packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts @@ -0,0 +1,75 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] { + const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s); + if (!match) return [{ kind: "stdout", ts, text: line }]; + + const stream = asString(match[2]).toLowerCase(); + const data = asRecord(safeJsonParse(asString(match[3]).trim())); + + if (stream === "assistant") { + const delta = asString(data?.delta); + if (delta.length > 0) { + return [{ kind: "assistant", ts, text: delta, delta: true }]; + } + + const text = asString(data?.text); + if (text.length > 0) { + return [{ kind: "assistant", ts, text }]; + } + return []; + } + + if (stream === "error") { + const message = asString(data?.error) || asString(data?.message); + return message ? [{ kind: "stderr", ts, text: message }] : []; + } + + if (stream === "lifecycle") { + const phase = asString(data?.phase).toLowerCase(); + const message = asString(data?.error) || asString(data?.message); + if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) { + return [{ kind: "stderr", ts, text: message }]; + } + } + + return []; +} + +export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { + const normalized = normalizeOpenClawGatewayStreamLine(line); + if (normalized.stream === "stderr") { + return [{ kind: "stderr", ts, text: normalized.line }]; + } + + const trimmed = normalized.line.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith("[openclaw-gateway:event]")) { + return parseAgentEventLine(trimmed, ts); + } + + if (trimmed.startsWith("[openclaw-gateway]")) { + return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }]; + } + + return [{ kind: "stdout", ts, text: normalized.line }]; +} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/openclaw-gateway/tsconfig.json similarity index 100% rename from packages/adapters/openclaw/tsconfig.json rename to packages/adapters/openclaw-gateway/tsconfig.json diff --git a/packages/adapters/openclaw/CHANGELOG.md b/packages/adapters/openclaw/CHANGELOG.md deleted file mode 100644 index 79174ae2..00000000 --- a/packages/adapters/openclaw/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -# @paperclipai/adapter-openclaw - -## 0.2.7 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.7 - -## 0.2.6 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.6 - -## 0.2.5 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.5 - -## 0.2.4 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.4 - -## 0.2.3 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.3 - -## 0.2.2 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.2 - -## 0.2.1 - -### Patch Changes - -- Version bump (patch) -- Updated dependencies - - @paperclipai/adapter-utils@0.2.1 diff --git a/packages/adapters/openclaw/src/cli/format-event.ts b/packages/adapters/openclaw/src/cli/format-event.ts deleted file mode 100644 index c0c0c910..00000000 --- a/packages/adapters/openclaw/src/cli/format-event.ts +++ /dev/null @@ -1,18 +0,0 @@ -import pc from "picocolors"; - -export function printOpenClawStreamEvent(raw: string, debug: boolean): void { - const line = raw.trim(); - if (!line) return; - - if (!debug) { - console.log(line); - return; - } - - if (line.startsWith("[openclaw]")) { - console.log(pc.cyan(line)); - return; - } - - console.log(pc.gray(line)); -} diff --git a/packages/adapters/openclaw/src/cli/index.ts b/packages/adapters/openclaw/src/cli/index.ts deleted file mode 100644 index 107ebf8b..00000000 --- a/packages/adapters/openclaw/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printOpenClawStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/openclaw/src/index.ts b/packages/adapters/openclaw/src/index.ts deleted file mode 100644 index d7399505..00000000 --- a/packages/adapters/openclaw/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const type = "openclaw"; -export const label = "OpenClaw"; - -export const models: { id: string; label: string }[] = []; - -export const agentConfigurationDoc = `# openclaw agent configuration - -Adapter: openclaw - -Use when: -- You run an OpenClaw agent remotely and wake it via webhook. -- You want Paperclip heartbeat/task events delivered over HTTP. - -Don't use when: -- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process). -- The OpenClaw endpoint is not reachable from the Paperclip server. - -Core fields: -- url (string, required): OpenClaw webhook endpoint URL -- method (string, optional): HTTP method, default POST -- headers (object, optional): extra HTTP headers for webhook calls -- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth -- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload - -Operational fields: -- timeoutSec (number, optional): request timeout in seconds (default 30) -`; diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts deleted file mode 100644 index c0de9f4e..00000000 --- a/packages/adapters/openclaw/src/server/execute.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asNumber, asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; -import { parseOpenClawResponse } from "./parse.js"; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const { config, runId, agent, context, onLog, onMeta } = ctx; - const url = asString(config.url, "").trim(); - if (!url) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw adapter missing url", - errorCode: "openclaw_url_missing", - }; - } - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - const timeoutSec = Math.max(1, asNumber(config.timeoutSec, 30)); - const headersConfig = parseObject(config.headers) as Record; - const payloadTemplate = parseObject(config.payloadTemplate); - const webhookAuthHeader = nonEmpty(config.webhookAuthHeader); - - const headers: Record = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersConfig)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = webhookAuthHeader; - } - - const wakePayload = { - runId, - agentId: agent.id, - companyId: agent.companyId, - taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), - issueId: nonEmpty(context.issueId), - wakeReason: nonEmpty(context.wakeReason), - wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), - approvalId: nonEmpty(context.approvalId), - approvalStatus: nonEmpty(context.approvalStatus), - issueIds: Array.isArray(context.issueIds) - ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : [], - }; - - const body = { - ...payloadTemplate, - paperclip: { - ...wakePayload, - context, - }, - }; - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "webhook", - commandArgs: [method, url], - context, - }); - } - - await onLog("stdout", `[openclaw] invoking ${method} ${url}\n`); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000); - - try { - const response = await fetch(url, { - method, - headers, - body: JSON.stringify(body), - signal: controller.signal, - }); - - const responseText = await response.text(); - if (responseText.trim().length > 0) { - await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`); - } else { - await onLog("stdout", `[openclaw] response (${response.status}) \n`); - } - - if (!response.ok) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `OpenClaw webhook failed with status ${response.status}`, - errorCode: "openclaw_http_error", - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${method} ${url}`, - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - await onLog("stderr", `[openclaw] request timed out after ${timeoutSec}s\n`); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - errorCode: "timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts deleted file mode 100644 index 5045c202..00000000 --- a/packages/adapters/openclaw/src/server/parse.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function parseOpenClawResponse(text: string): Record | null { - try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return null; - } - return parsed as Record; - } catch { - return null; - } -} - -export function isOpenClawUnknownSessionError(_text: string): boolean { - return false; -} diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts deleted file mode 100644 index ecc1e43c..00000000 --- a/packages/adapters/openclaw/src/server/test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { - if (checks.some((check) => check.level === "error")) return "fail"; - if (checks.some((check) => check.level === "warn")) return "warn"; - return "pass"; -} - -function isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - -function normalizeHostname(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("[")) { - const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); - } - const firstColon = trimmed.indexOf(":"); - if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); - return trimmed.toLowerCase(); -} - -function pushDeploymentDiagnostics( - checks: AdapterEnvironmentCheck[], - ctx: AdapterEnvironmentTestContext, - endpointUrl: URL | null, -) { - const mode = ctx.deployment?.mode; - const exposure = ctx.deployment?.exposure; - const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); - const allowSet = new Set( - (ctx.deployment?.allowedHostnames ?? []) - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; - - if (!mode) return; - - checks.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, - }); - - if (mode === "authenticated" && exposure === "private") { - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - checks.push({ - code: "openclaw_private_bind_hostname_not_allowed", - level: "warn", - message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, - }); - } - - if (!bindHost || isLoopbackHost(bindHost)) { - checks.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", - }); - } - - if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { - checks.push({ - code: "openclaw_private_no_allowed_hostnames", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs on another machine.", - }); - } - } - - if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { - checks.push({ - code: "openclaw_public_http_endpoint", - level: "warn", - message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", - }); - } -} - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const checks: AdapterEnvironmentCheck[] = []; - const config = parseObject(ctx.config); - const urlValue = asString(config.url, ""); - - if (!urlValue) { - checks.push({ - code: "openclaw_url_missing", - level: "error", - message: "OpenClaw adapter requires a webhook URL.", - hint: "Set adapterConfig.url to your OpenClaw webhook endpoint.", - }); - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; - } - - let url: URL | null = null; - try { - url = new URL(urlValue); - } catch { - checks.push({ - code: "openclaw_url_invalid", - level: "error", - message: `Invalid URL: ${urlValue}`, - }); - } - - if (url && url.protocol !== "http:" && url.protocol !== "https:") { - checks.push({ - code: "openclaw_url_protocol_invalid", - level: "error", - message: `Unsupported URL protocol: ${url.protocol}`, - hint: "Use an http:// or https:// endpoint.", - }); - } - - if (url) { - checks.push({ - code: "openclaw_url_valid", - level: "info", - message: `Configured endpoint: ${url.toString()}`, - }); - - if (isLoopbackHost(url.hostname)) { - checks.push({ - code: "openclaw_loopback_endpoint", - level: "warn", - message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", - }); - } - } - - pushDeploymentDiagnostics(checks, ctx, url); - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - checks.push({ - code: "openclaw_method_configured", - level: "info", - message: `Configured method: ${method}`, - }); - - if (url && (url.protocol === "http:" || url.protocol === "https:")) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - const response = await fetch(url, { method: "HEAD", signal: controller.signal }); - if (!response.ok && response.status !== 405 && response.status !== 501) { - checks.push({ - code: "openclaw_endpoint_probe_unexpected_status", - level: "warn", - message: `Endpoint probe returned HTTP ${response.status}.`, - hint: "Verify OpenClaw webhook reachability and auth/network settings.", - }); - } else { - checks.push({ - code: "openclaw_endpoint_probe_ok", - level: "info", - message: "Endpoint responded to a HEAD probe.", - }); - } - } catch (err) { - checks.push({ - code: "openclaw_endpoint_probe_failed", - level: "warn", - message: err instanceof Error ? err.message : "Endpoint probe failed", - hint: "This may be expected in restricted networks; validate from the Paperclip server host.", - }); - } finally { - clearTimeout(timeout); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts deleted file mode 100644 index 54bb2fe2..00000000 --- a/packages/adapters/openclaw/src/ui/build-config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -export function buildOpenClawConfig(v: CreateConfigValues): Record { - const ac: Record = {}; - if (v.url) ac.url = v.url; - ac.method = "POST"; - ac.timeoutSec = 30; - return ac; -} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts deleted file mode 100644 index f3f1905e..00000000 --- a/packages/adapters/openclaw/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseOpenClawStdoutLine } from "./parse-stdout.js"; -export { buildOpenClawConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts deleted file mode 100644 index 4be215e8..00000000 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; - -export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - return [{ kind: "stdout", ts, text: line }]; -} diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index e52dfab9..ef07f9bf 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -4,4 +4,4 @@ ### Patch Changes -- Added initial `opencode_local` adapter package for local OpenCode execution +- Add local OpenCode adapter package with server/UI/CLI modules. diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index f53722fb..7c6b48a3 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts index 37b34250..58d2038d 100644 --- a/packages/adapters/opencode-local/src/cli/format-event.ts +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -1,5 +1,13 @@ import pc from "picocolors"; +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } -function printToolEvent(part: Record): void { - const tool = asString(part.tool, "tool"); - const callId = asString(part.callID, asString(part.id, "")); - const state = asRecord(part.state); - const status = asString(state?.status); - const input = state?.input; - const output = asString(state?.output).replace(/\s+$/, ""); - const metadata = asRecord(state?.metadata); - const exit = asNumber(metadata?.exit, NaN); - const isError = - status === "failed" || - status === "error" || - status === "cancelled" || - (Number.isFinite(exit) && exit !== 0); - - console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`)); - if (input !== undefined) { - try { - console.log(pc.gray(JSON.stringify(input, null, 2))); - } catch { - console.log(pc.gray(String(input))); - } - } - - if (status || output) { - const summary = [ - "tool_result", - status ? `status=${status}` : "", - Number.isFinite(exit) ? `exit=${exit}` : "", - ] - .filter(Boolean) - .join(" "); - console.log((isError ? pc.red : pc.cyan)(summary)); - if (output) { - console.log((isError ? pc.red : pc.gray)(output)); - } +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const data = asRecord(rec.data); + const message = + asString(rec.message) || + asString(data?.message) || + asString(rec.name) || + ""; + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; } } @@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const line = raw.trim(); if (!line) return; - let parsed: Record | null = null; - try { - parsed = JSON.parse(line) as Record; - } catch { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { console.log(line); return; } @@ -74,18 +59,41 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { if (type === "text") { const part = asRecord(parsed.part); - const text = asString(part?.text); + const text = asString(part?.text).trim(); if (text) console.log(pc.green(`assistant: ${text}`)); return; } + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + if (type === "tool_use") { const part = asRecord(parsed.part); - if (part) { - printToolEvent(part); - } else { - console.log(pc.yellow("tool_use")); + const tool = asString(part?.tool, "tool"); + const callID = asString(part?.callID); + const state = asRecord(part?.state); + const status = asString(state?.status); + const isError = status === "error"; + const metadata = asRecord(state?.metadata); + + console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`)); + + if (status) { + const metaParts = [`status=${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`); + } + } + console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`)); } + + const output = (asString(state?.output) || asString(state?.error)).trim(); + if (output) console.log((isError ? pc.red : pc.gray)(output)); return; } @@ -93,20 +101,19 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const part = asRecord(parsed.part); const tokens = asRecord(part?.tokens); const cache = asRecord(tokens?.cache); - const reason = asString(part?.reason, "step_finish"); - const input = asNumber(tokens?.input); - const output = asNumber(tokens?.output); - const cached = asNumber(cache?.read); - const cost = asNumber(part?.cost); + const input = asNumber(tokens?.input, 0); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); + const cached = asNumber(cache?.read, 0); + const cost = asNumber(part?.cost, 0); + const reason = asString(part?.reason, "step"); console.log(pc.blue(`step finished: reason=${reason}`)); console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); return; } if (type === "error") { - const part = asRecord(parsed.part); - const message = asString(parsed.message) || asString(part?.message) || line; - console.log(pc.red(`error: ${message}`)); + const message = errorText(parsed.error ?? parsed.message); + if (message) console.log(pc.red(`error: ${message}`)); return; } diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 5f5be605..0603630d 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,8 +1,9 @@ export const type = "opencode_local"; export const label = "OpenCode (local)"; + export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; -export const models = [ +export const models: Array<{ id: string; label: string }> = [ { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, { id: "openai/gpt-5.2", label: "openai/gpt-5.2" }, @@ -20,14 +21,14 @@ Use when: - You want OpenCode session resume across heartbeats via --session Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - OpenCode CLI is not installed on the machine Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt -- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex) +- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) - variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max) - promptTemplate (string, optional): run prompt template - command (string, optional): defaults to "opencode" @@ -39,7 +40,9 @@ Operational fields: - graceSec (number, optional): SIGTERM grace period in seconds Notes: +- OpenCode supports multiple providers and models. Use \ + \`opencode models\` to list available options in provider/model format. +- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents. - Runs are executed with: opencode run --format json ... -- Prompts are passed as the final positional message argument. - Sessions are resumed with --session when stored session cwd matches current cwd. `; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index d0070dca..970896af 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -16,8 +16,8 @@ import { renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; -import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; +import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const PAPERCLIP_SKILLS_CANDIDATES = [ @@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string { ); } -function getEffectiveEnvValue(envOverrides: Record, key: string): string { - if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { - const raw = envOverrides[key]; - return typeof raw === "string" ? raw : ""; - } - const raw = process.env[key]; - return typeof raw === "string" ? raw : ""; -} - -function hasEffectiveEnvValue(envOverrides: Record, key: string): boolean { - return getEffectiveEnvValue(envOverrides, key).trim().length > 0; -} - -function resolveOpenCodeBillingType(env: Record): "api" | "subscription" { - return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; -} - -function resolveProviderFromModel(model: string): string | null { +function parseModelProvider(model: string | null): string | null { + if (!model) return null; const trimmed = model.trim(); - if (!trimmed) return null; - const slash = trimmed.indexOf("/"); - if (slash <= 0) return null; - return trimmed.slice(0, slash).toLowerCase(); -} - -function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean { - const haystack = `${stdout}\n${stderr}`; - return /ProviderModelNotFoundError|provider model not found/i.test(haystack); -} - -type ProviderModelNotFoundDetails = { - providerId: string | null; - modelId: string | null; - suggestions: string[]; -}; - -function parseProviderModelNotFoundDetails( - stdout: string, - stderr: string, -): ProviderModelNotFoundDetails | null { - if (!isProviderModelNotFoundFailure(stdout, stderr)) return null; - const haystack = `${stdout}\n${stderr}`; - - const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i); - const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i); - const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i); - const suggestions = suggestionsMatch - ? Array.from( - suggestionsMatch[1].matchAll(/"([^"]+)"/g), - (match) => match[1].trim(), - ).filter((value) => value.length > 0) - : []; - - return { - providerId: providerMatch?.[1]?.trim().toLowerCase() || null, - modelId: modelMatch?.[1]?.trim() || null, - suggestions, - }; -} - -function formatModelNotFoundError( - model: string, - providerFromModel: string | null, - details: ProviderModelNotFoundDetails | null, -): string { - const provider = details?.providerId || providerFromModel || "unknown"; - const missingModel = details?.modelId || model; - const suggestions = details?.suggestions ?? []; - const suggestionText = - suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : ""; - return ( - `OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` + - ` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` + - suggestionText - ); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; } function claudeSkillsHome(): string { @@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; - if (wakeTaskId) { - env.PAPERCLIP_TASK_ID = wakeTaskId; - } - if (wakeReason) { - env.PAPERCLIP_WAKE_REASON = wakeReason; - } - if (wakeCommentId) { - env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; - } - if (approvalId) { - env.PAPERCLIP_APPROVAL_ID = approvalId; - } - if (approvalStatus) { - env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; - } - if (linkedIssueIds.length > 0) { - env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - } - if (effectiveWorkspaceCwd) { - env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; - } - if (workspaceSource) { - env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; - } - if (workspaceId) { - env.PAPERCLIP_WORKSPACE_ID = workspaceId; - } - if (workspaceRepoUrl) { - env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; - } - if (workspaceRepoRef) { - env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; - } - if (workspaceHints.length > 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - } - for (const [k, v] of Object.entries(envConfig)) { - if (typeof v === "string") env[k] = v; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; } if (!hasExplicitApiKey && authToken) { env.PAPERCLIP_API_KEY = authToken; } - const billingType = resolveOpenCodeBillingType(env); - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, + cwd, + env: runtimeEnv, + }); + const timeoutSec = asNumber(config.timeoutSec, 0); const graceSec = asNumber(config.graceSec, 20); const extraArgs = (() => { @@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (!instructionsFilePath) return [] as string[]; + if (!resolvedInstructionsFilePath) return [] as string[]; if (instructionsPrefix.length > 0) { return [ - `Loaded agent instructions from ${instructionsFilePath}`, - `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, ]; } return [ - `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, ]; })(); @@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - args.push(prompt); return args; }; @@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (idx === args.length - 1) return ``; - return value; - }), + commandArgs: [...args, ``], env: redactEnvForLogs(env), prompt, context, @@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise; }, clearSessionOnMissingSession = false, @@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr); - const fallbackErrorMessage = modelNotFound - ? formatModelNotFoundError(model, providerFromModel, modelNotFound) - : parsedError || - stderrLine || - `OpenCode exited with code ${attempt.proc.exitCode ?? -1}`; + 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: attempt.proc.exitCode, + exitCode: synthesizedExitCode, signal: attempt.proc.signal, timedOut: false, - errorMessage: - (attempt.proc.exitCode ?? 0) === 0 - ? null - : fallbackErrorMessage, - usage: attempt.parsed.usage, + 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: providerFromModel, - model, - billingType, + provider: 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 && !resolvedSessionId), + 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 && - !initial.proc.timedOut && - (initial.proc.exitCode ?? 0) !== 0 && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) ) { await onLog( "stderr", - `[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, ); const retry = await runAttempt(null); return toResult(retry, true); diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts index 17300e75..a2275d42 100644 --- a/packages/adapters/opencode-local/src/server/index.ts +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -1,6 +1,3 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; function readNonEmptyString(value: unknown): string | null { @@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = { ); }, }; + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + listOpenCodeModels, + discoverOpenCodeModels, + ensureOpenCodeModelConfiguredAndAvailable, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; +export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts new file mode 100644 index 00000000..cd49e4a2 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureOpenCodeModelConfiguredAndAvailable, + listOpenCodeModels, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; + +describe("openCode models", () => { + afterEach(() => { + delete process.env.PAPERCLIP_OPENCODE_COMMAND; + resetOpenCodeModelsCacheForTests(); + }); + + it("returns an empty list when discovery command is unavailable", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect(listOpenCodeModels()).resolves.toEqual([]); + }); + + it("rejects when model is missing", async () => { + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), + ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); + }); + + it("rejects when discovery cannot run for configured model", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openai/gpt-5", + }), + ).rejects.toThrow("Failed to start command"); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts new file mode 100644 index 00000000..dd2eb2c6 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -0,0 +1,195 @@ +import { createHash } from "node:crypto"; +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { + asString, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; + +const MODELS_CACHE_TTL_MS = 60_000; + +function resolveOpenCodeCommand(input: unknown): string { + const envOverride = + typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"; + return asString(input, envOverride); +} + +const discoveryCache = new Map(); +const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; +const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function sortModels(models: AdapterModel[]): AdapterModel[] { + return [...models].sort((a, b) => + a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), + ); +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelsOutput(stdout: string): AdapterModel[] { + const parsed: AdapterModel[] = []; + for (const raw of stdout.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const firstToken = line.split(/\s+/)[0]?.trim() ?? ""; + if (!firstToken.includes("/")) continue; + const provider = firstToken.slice(0, firstToken.indexOf("/")).trim(); + const model = firstToken.slice(firstToken.indexOf("/") + 1).trim(); + if (!provider || !model) continue; + parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` }); + } + return dedupeModels(parsed); +} + +function normalizeEnv(input: unknown): Record { + const envInput = typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; + const env: Record = {}; + for (const [key, value] of Object.entries(envInput)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +function isVolatileEnvKey(key: string): boolean { + if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; + return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +function hashValue(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function discoveryCacheKey(command: string, cwd: string, env: Record) { + const envKey = Object.entries(env) + .filter(([key]) => !isVolatileEnvKey(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${hashValue(value)}`) + .join("\n"); + return `${command}\n${cwd}\n${envKey}`; +} + +function pruneExpiredDiscoveryCache(now: number) { + for (const [key, value] of discoveryCache.entries()) { + if (value.expiresAt <= now) discoveryCache.delete(key); + } +} + +export async function discoverOpenCodeModels(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = resolveOpenCodeCommand(input.command); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + + const result = await runChildProcess( + `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + ["models"], + { + cwd, + env: runtimeEnv, + timeoutSec: 20, + graceSec: 3, + onLog: async () => {}, + }, + ); + + if (result.timedOut) { + throw new Error("`opencode models` timed out."); + } + if ((result.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); + throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); + } + + return sortModels(parseModelsOutput(result.stdout)); +} + +export async function discoverOpenCodeModelsCached(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = resolveOpenCodeCommand(input.command); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const key = discoveryCacheKey(command, cwd, env); + const now = Date.now(); + pruneExpiredDiscoveryCache(now); + const cached = discoveryCache.get(key); + if (cached && cached.expiresAt > now) return cached.models; + + const models = await discoverOpenCodeModels({ command, cwd, env }); + discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); + return models; +} + +export async function ensureOpenCodeModelConfiguredAndAvailable(input: { + model?: unknown; + command?: unknown; + cwd?: unknown; + env?: unknown; +}): Promise { + const model = asString(input.model, "").trim(); + if (!model) { + throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); + } + + const models = await discoverOpenCodeModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + + if (models.length === 0) { + throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } + + return models; +} + +export async function listOpenCodeModels(): Promise { + try { + return await discoverOpenCodeModelsCached(); + } catch { + return []; + } +} + +export function resetOpenCodeModelsCacheForTests() { + discoveryCache.clear(); +} diff --git a/packages/adapters/opencode-local/src/server/parse.test.ts b/packages/adapters/opencode-local/src/server/parse.test.ts new file mode 100644 index 00000000..5f4a3a36 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; + +describe("parseOpenCodeJsonl", () => { + it("parses assistant text, usage, cost, and errors", () => { + const stdout = [ + JSON.stringify({ + type: "text", + sessionID: "session_123", + part: { text: "Hello from OpenCode" }, + }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { + reason: "done", + cost: 0.0025, + tokens: { + input: 120, + output: 40, + reasoning: 10, + cache: { read: 20, write: 0 }, + }, + }, + }), + JSON.stringify({ + type: "error", + sessionID: "session_123", + error: { message: "model unavailable" }, + }), + ].join("\n"); + + const parsed = parseOpenCodeJsonl(stdout); + expect(parsed.sessionId).toBe("session_123"); + expect(parsed.summary).toBe("Hello from OpenCode"); + expect(parsed.usage).toEqual({ + inputTokens: 120, + cachedInputTokens: 20, + outputTokens: 50, + }); + expect(parsed.costUsd).toBeCloseTo(0.0025, 6); + expect(parsed.errorMessage).toContain("model unavailable"); + }); + + it("detects unknown session errors", () => { + expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true); + expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true); + expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index 2b028566..96af0ed1 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -1,10 +1,17 @@ -import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; -function asErrorText(value: unknown): string { +function errorText(value: unknown): string { if (typeof value === "string") return value; const rec = parseObject(value); - const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, ""); + const message = asString(rec.message, "").trim(); if (message) return message; + const data = parseObject(rec.data); + const nestedMessage = asString(data.message, "").trim(); + if (nestedMessage) return nestedMessage; + const name = asString(rec.name, "").trim(); + if (name) return name; + const code = asString(rec.code, "").trim(); + if (code) return code; try { return JSON.stringify(rec); } catch { @@ -15,13 +22,13 @@ function asErrorText(value: unknown): string { export function parseOpenCodeJsonl(stdout: string) { let sessionId: string | null = null; const messages: string[] = []; - let errorMessage: string | null = null; - let totalCostUsd = 0; + const errors: string[] = []; const usage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, }; + let costUsd = 0; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) { const event = parseJson(line); if (!event) continue; - const foundSession = asString(event.sessionID, "").trim(); - if (foundSession) sessionId = foundSession; + const currentSessionId = asString(event.sessionID, "").trim(); + if (currentSessionId) sessionId = currentSessionId; const type = asString(event.type, ""); @@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) { const cache = parseObject(tokens.cache); usage.inputTokens += asNumber(tokens.input, 0); usage.cachedInputTokens += asNumber(cache.read, 0); - usage.outputTokens += asNumber(tokens.output, 0); - totalCostUsd += asNumber(part.cost, 0); + usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); + costUsd += asNumber(part.cost, 0); + continue; + } + + if (type === "tool_use") { + const part = parseObject(event.part); + const state = parseObject(part.state); + if (asString(state.status, "") === "error") { + const text = asString(state.error, "").trim(); + if (text) errors.push(text); + } continue; } if (type === "error") { - const part = parseObject(event.part); - const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim(); - if (msg) errorMessage = msg; + const text = errorText(event.error ?? event.message).trim(); + if (text) errors.push(text); + continue; } } @@ -64,8 +81,8 @@ export function parseOpenCodeJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, - costUsd: totalCostUsd > 0 ? totalCostUsd : null, - errorMessage, + costUsd, + errorMessage: errors.length > 0 ? errors.join("\n") : null, }; } @@ -76,7 +93,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b .filter(Boolean) .join("\n"); - return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test( + return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( haystack, ); } diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 99c0606e..5bb7aa36 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -12,8 +12,7 @@ import { ensurePathInEnv, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; -import path from "node:path"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; +import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { @@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT return "pass"; } -function isNonEmpty(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function getEffectiveEnvValue(envOverrides: Record, key: string): string { - if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { - const raw = envOverrides[key]; - return typeof raw === "string" ? raw : ""; - } - const raw = process.env[key]; - return typeof raw === "string" ? raw : ""; -} - function firstNonEmptyLine(text: string): string { return ( text @@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string { ); } -function commandLooksLike(command: string, expected: string): boolean { - const base = path.basename(command).toLowerCase(); - return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; -} - function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); if (!raw) return null; const clean = raw.replace(/\s+/g, " ").trim(); const max = 240; - return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; + return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; } const OPENCODE_AUTH_REQUIRED_RE = - /(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i; -const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i; + /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i; export async function testEnvironment( ctx: AdapterEnvironmentTestContext, @@ -70,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -90,100 +79,186 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); - checks.push({ - code: "opencode_command_resolvable", - level: "info", - 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, - }); - } - const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY"); - const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY"); - if (isNonEmpty(effectiveOpenAiKey)) { - const source = configDefinesOpenAiKey ? "adapter config env" : "server environment"; - checks.push({ - code: "opencode_openai_api_key_present", - level: "info", - message: "OPENAI_API_KEY is set for OpenCode authentication.", - detail: `Detected in ${source}.`, - }); - } else { + const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null; + if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") { checks.push({ code: "opencode_openai_api_key_missing", level: "warn", - message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.", - hint: configDefinesOpenAiKey - ? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override." - : "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.", + message: "OPENAI_API_KEY override is empty.", + hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.", }); } + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + 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({ + code: "opencode_command_resolvable", + level: "info", + 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, + }); + } + } + const canRunProbe = checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - if (canRunProbe) { - if (!commandLooksLike(command, "opencode")) { - checks.push({ - code: "opencode_hello_probe_skipped_custom_command", - level: "info", - message: "Skipped hello probe because command is not `opencode`.", - detail: command, - hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.", + + 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({ + 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) { + 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.`, + }); + } + } 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: "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"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, + cwd, + env: runtimeEnv, }); - } else { - const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim(); - const variant = asString(config.variant, asString(config.effort, "")).trim(); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); + 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.", + }); + } + } - const args = ["run", "--format", "json"]; - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - args.push("Respond with hello."); + 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, - env, - timeoutSec: 45, + 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(); - const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence); - const modelProvider = (() => { - const slash = model.indexOf("/"); - if (slash <= 0) return "openai"; - return model.slice(0, slash).toLowerCase(); - })(); 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, verify `opencode run --format json \"Respond with hello\"` manually.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", }); - } else if ((probe.exitCode ?? 1) === 0) { + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { const summary = parsed.summary.trim(); const hasHello = /\bhello\b/i.test(summary); checks.push({ @@ -196,24 +271,24 @@ export async function testEnvironment( ...(hasHello ? {} : { - hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.", + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", }), }); - } else if (modelNotFound) { + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_model_unavailable", level: "warn", - message: `OpenCode could not run model \`${model}\`.`, + message: "The configured model was not found by the provider.", ...(detail ? { detail } : {}), - hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`, + 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 CLI is installed, but authentication is not ready.", + message: "OpenCode is installed, but provider authentication is not ready.", ...(detail ? { detail } : {}), - hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.", + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", }); } else { checks.push({ @@ -221,9 +296,17 @@ export async function testEnvironment( level: "error", message: "OpenCode hello probe failed.", ...(detail ? { detail } : {}), - hint: "Run `opencode run --format json \"Respond with hello\"` 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.", + }); } } diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 88b3c0ac..3abfd6cd 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -1,5 +1,4 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js"; function parseCommaArgs(value: string): string[] { return value @@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record, ts: string): TranscriptEntry[] { + const part = asRecord(parsed.part); + if (!part) return [{ kind: "system", ts, text: "tool event" }]; + + const toolName = asString(part.tool, "tool"); + const state = asRecord(part.state); + const input = state?.input ?? {}; + const callEntry: TranscriptEntry = { + kind: "tool_call", + ts, + name: toolName, + input, + }; + + const status = asString(state?.status); + if (status !== "completed" && status !== "error") return [callEntry]; + + const rawOutput = + asString(state?.output) || + asString(state?.error) || + asString(part.title) || + `${toolName} ${status}`; + + const metadata = asRecord(state?.metadata); + const headerParts: string[] = [`status: ${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`); + } } + const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim(); + + return [ + callEntry, + { + kind: "tool_result", + ts, + toolUseId: asString(part.callID) || asString(part.id, toolName), + content, + isError: status === "error", + }, + ]; } export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] { @@ -51,6 +91,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt const type = asString(parsed.type); + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "assistant", ts, text }]; + } + + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "thinking", ts, text }]; + } + + if (type === "tool_use") { + return parseToolUse(parsed, ts); + } + if (type === "step_start") { const sessionId = asString(parsed.sessionID); return [ @@ -62,93 +120,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt ]; } - if (type === "text") { - const part = asRecord(parsed.part); - const text = asString(part?.text).trim(); - if (!text) return []; - return [{ kind: "assistant", ts, text }]; - } - - if (type === "tool_use") { - const part = asRecord(parsed.part); - const toolUseId = asString(part?.callID, asString(part?.id, "tool_use")); - const toolName = asString(part?.tool, "tool"); - const state = asRecord(part?.state); - const input = state?.input ?? {}; - const output = asString(state?.output).trim(); - const status = asString(state?.status).trim(); - const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN); - const isError = - status === "failed" || - status === "error" || - status === "cancelled" || - (Number.isFinite(exitCode) && exitCode !== 0); - - const entries: TranscriptEntry[] = [ - { - kind: "tool_call", - ts, - name: toolName, - input, - }, - ]; - - if (status || output) { - const lines: string[] = []; - if (status) lines.push(`status: ${status}`); - if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`); - if (output) { - if (lines.length > 0) lines.push(""); - if (isJsonLike(output)) { - try { - lines.push(JSON.stringify(JSON.parse(output), null, 2)); - } catch { - lines.push(output); - } - } else { - lines.push(output); - } - } - entries.push({ - kind: "tool_result", - ts, - toolUseId, - content: lines.join("\n").trim() || "tool completed", - isError, - }); - } - - return entries; - } - if (type === "step_finish") { const part = asRecord(parsed.part); const tokens = asRecord(part?.tokens); const cache = asRecord(tokens?.cache); - const reason = asString(part?.reason); + const reason = asString(part?.reason, "step"); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); return [ { kind: "result", ts, text: reason, - inputTokens: asNumber(tokens?.input), - outputTokens: asNumber(tokens?.output), - cachedTokens: asNumber(cache?.read), - costUsd: asNumber(part?.cost), - subtype: reason || "step_finish", - isError: reason === "error" || reason === "failed", + inputTokens: asNumber(tokens?.input, 0), + outputTokens: output, + cachedTokens: asNumber(cache?.read, 0), + costUsd: asNumber(part?.cost, 0), + subtype: reason, + isError: false, errors: [], }, ]; } if (type === "error") { - const message = - asString(parsed.message) || - asString(asRecord(parsed.part)?.message) || - stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) || - line; - return [{ kind: "stderr", ts, text: message }]; + const text = errorText(parsed.error ?? parsed.message); + return [{ kind: "stderr", ts, text: text || line }]; } return [{ kind: "stdout", ts, text: line }]; diff --git a/packages/adapters/opencode-local/vitest.config.ts b/packages/adapters/opencode-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/opencode-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/pi-local/package.json similarity index 91% rename from packages/adapters/openclaw/package.json rename to packages/adapters/pi-local/package.json index 22acb5e3..1184c1ca 100644 --- a/packages/adapters/openclaw/package.json +++ b/packages/adapters/pi-local/package.json @@ -1,6 +1,6 @@ { - "name": "@paperclipai/adapter-openclaw", - "version": "0.2.7", + "name": "@paperclipai/adapter-pi-local", + "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.ts", @@ -44,6 +44,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/pi-local/src/cli/format-event.ts b/packages/adapters/pi-local/src/cli/format-event.ts new file mode 100644 index 00000000..e93319a6 --- /dev/null +++ b/packages/adapters/pi-local/src/cli/format-event.ts @@ -0,0 +1,107 @@ +import pc from "picocolors"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function extractTextContent(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!) + .join(""); +} + +export function printPiStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "agent_start") { + console.log(pc.blue("Pi agent started")); + return; + } + + if (type === "agent_end") { + console.log(pc.blue("Pi agent finished")); + return; + } + + if (type === "turn_start") { + console.log(pc.blue("Turn started")); + return; + } + + if (type === "turn_end") { + const message = asRecord(parsed.message); + if (message) { + const content = message.content as string | Array<{ type: string; text?: string }>; + const text = extractTextContent(content); + if (text) { + console.log(pc.green(`assistant: ${text}`)); + } + } + return; + } + + if (type === "message_update") { + const assistantEvent = asRecord(parsed.assistantMessageEvent); + if (assistantEvent) { + const msgType = asString(assistantEvent.type); + if (msgType === "text_delta") { + const delta = asString(assistantEvent.delta); + if (delta) { + console.log(pc.green(delta)); + } + } + } + return; + } + + if (type === "tool_execution_start") { + const toolName = asString(parsed.toolName); + const args = parsed.args; + console.log(pc.yellow(`tool_start: ${toolName}`)); + if (args !== undefined) { + try { + console.log(pc.gray(JSON.stringify(args, null, 2))); + } catch { + console.log(pc.gray(String(args))); + } + } + return; + } + + if (type === "tool_execution_end") { + const result = parsed.result; + const isError = parsed.isError === true; + const output = typeof result === "string" ? result : JSON.stringify(result); + if (output) { + console.log((isError ? pc.red : pc.gray)(output)); + } + return; + } + + console.log(line); +} diff --git a/packages/adapters/pi-local/src/cli/index.ts b/packages/adapters/pi-local/src/cli/index.ts new file mode 100644 index 00000000..94d5961a --- /dev/null +++ b/packages/adapters/pi-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printPiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts new file mode 100644 index 00000000..a81750c3 --- /dev/null +++ b/packages/adapters/pi-local/src/index.ts @@ -0,0 +1,40 @@ +export const type = "pi_local"; +export const label = "Pi (local)"; + +export const models: Array<{ id: string; label: string }> = []; + +export const agentConfigurationDoc = `# pi_local agent configuration + +Adapter: pi_local + +Use when: +- You want Paperclip to run Pi (the AI coding agent) locally as the agent runtime +- You want provider/model routing in Pi format (--provider --model ) +- You want Pi session resume across heartbeats via --session +- You need Pi's tool set (read, bash, edit, write, grep, find, ls) + +Don't use when: +- You need webhook-style external invocation (use openclaw_gateway or http) +- You only need one-shot shell commands (use process) +- Pi CLI is not installed on the machine + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file appended to system prompt via --append-system-prompt +- promptTemplate (string, optional): user prompt template passed via -p flag +- model (string, required): Pi model id in provider/model format (for example xai/grok-4) +- thinking (string, optional): thinking level (off, minimal, low, medium, high, xhigh) +- command (string, optional): defaults to "pi" +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Pi supports multiple providers and models. Use \`pi --list-models\` to list available options. +- Paperclip requires an explicit \`model\` value for \`pi_local\` agents. +- Sessions are stored in ~/.pi/paperclips/ and resumed with --session. +- All tools (read, bash, edit, write, grep, find, ls) are enabled by default. +- Agent instructions are appended to Pi's system prompt via --append-system-prompt, while the user task is sent via -p. +`; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts new file mode 100644 index 00000000..23cad28b --- /dev/null +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -0,0 +1,478 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asString, + asNumber, + asStringArray, + parseObject, + buildPaperclipEnv, + redactEnvForLogs, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; +import { ensurePiModelConfiguredAndAvailable } from "./models.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../skills"), + path.resolve(__moduleDir, "../../../../../skills"), +]; + +const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips"); + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelProvider(model: string | null): string | null { + if (!model) return null; + const trimmed = model.trim(); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; +} + +function parseModelId(model: string | null): string | null { + if (!model) return null; + const trimmed = model.trim(); + if (!trimmed.includes("/")) return trimmed || null; + return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; +} + +async function resolvePaperclipSkillsDir(): Promise { + for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { + const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); + if (isDir) return candidate; + } + return null; +} + +async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { + const skillsDir = await resolvePaperclipSkillsDir(); + if (!skillsDir) return; + + const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); + await fs.mkdir(piSkillsHome, { recursive: true }); + + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const source = path.join(skillsDir, entry.name); + const target = path.join(piSkillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (existing) continue; + + try { + await fs.symlink(source, target); + await onLog( + "stderr", + `[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +async function ensureSessionsDir(): Promise { + await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true }); + return PAPERCLIP_SESSIONS_DIR; +} + +function buildSessionPath(agentId: string, timestamp: string): string { + const safeTimestamp = timestamp.replace(/[:.]/g, "-"); + return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`); +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "pi"); + const model = asString(config.model, "").trim(); + const thinking = asString(config.thinking, "").trim(); + + // Parse model into provider and model id + const provider = parseModelProvider(model); + const modelId = parseModelId(model); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + // Ensure sessions directory exists + await ensureSessionsDir(); + + // Inject skills + await ensurePiSkillsInjected(onLog); + + // Build environment + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + // Validate model is available before execution + await ensurePiModelConfiguredAndAvailable({ + model, + 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); + })(); + + // Handle session + 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 sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString()); + + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + // Ensure session file exists (Pi requires this on first run) + if (!canResumeSession) { + try { + await fs.writeFile(sessionPath, "", { flag: "wx" }); + } catch (err) { + // File may already exist, that's ok + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + } + } + + // Handle instructions file and build system prompt extension + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + + let systemPromptExtension = ""; + let instructionsReadFailed = false; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + systemPromptExtension = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}.\n\n` + + `You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`, + ); + } catch (err) { + instructionsReadFailed = true; + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + // Fall back to base prompt template + systemPromptExtension = promptTemplate; + } + } else { + systemPromptExtension = promptTemplate; + } + + const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + + // User prompt is simple - just the rendered prompt template without instructions + const userPrompt = renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + + const commandNotes = (() => { + if (!resolvedInstructionsFilePath) return [] as string[]; + if (instructionsReadFailed) { + return [ + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ]; + } + return [ + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, + `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, + ]; + })(); + + const buildArgs = (sessionFile: string): string[] => { + const args: string[] = []; + + // Use RPC mode for proper lifecycle management (waits for agent completion) + args.push("--mode", "rpc"); + + // Use --append-system-prompt to extend Pi's default system prompt + args.push("--append-system-prompt", renderedSystemPromptExtension); + + if (provider) args.push("--provider", provider); + if (modelId) args.push("--model", modelId); + if (thinking) args.push("--thinking", thinking); + + args.push("--tools", "read,bash,edit,write,grep,find,ls"); + args.push("--session", sessionFile); + + if (extraArgs.length > 0) args.push(...extraArgs); + + 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 args = buildArgs(sessionFile); + if (onMeta) { + await onMeta({ + adapterType: "pi_local", + command, + cwd, + commandNotes, + commandArgs: args, + env: redactEnvForLogs(env), + prompt: userPrompt, + context, + }); + } + + // Buffer stdout by lines to handle partial JSON chunks + let stdoutBuffer = ""; + const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stderr") { + // Pass stderr through immediately (not JSONL) + await onLog(stream, chunk); + return; + } + + // Buffer stdout and emit only complete lines + stdoutBuffer += chunk; + const lines = stdoutBuffer.split("\n"); + // Keep the last (potentially incomplete) line in the buffer + stdoutBuffer = lines.pop() || ""; + + // Emit complete lines + for (const line of lines) { + if (line) { + await onLog(stream, line + "\n"); + } + } + }; + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog: bufferedOnLog, + stdin: buildRpcStdin(), + }); + + // Flush any remaining buffer content + if (stdoutBuffer) { + await onLog("stdout", stdoutBuffer); + } + + return { + proc, + rawStderr: proc.stderr, + parsed: parsePiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + 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 = clearSessionOnMissingSession ? null : sessionPath; + const resolvedSessionParams = resolvedSessionId + ? { sessionId: resolvedSessionId, cwd } + : null; + + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const fallbackErrorMessage = stderrLine || `Pi exited with code ${rawExitCode ?? -1}`; + + return { + exitCode: rawExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (rawExitCode ?? 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: provider, + model: model, + billingType: "unknown", + costUsd: attempt.parsed.usage.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(), + clearSession: Boolean(clearSessionOnMissingSession), + }; + }; + + const initial = await runAttempt(sessionPath); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0); + + if ( + canResumeSession && + initialFailed && + isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stderr", + `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const newSessionPath = buildSessionPath(agent.id, new Date().toISOString()); + try { + await fs.writeFile(newSessionPath, "", { flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + } + const retry = await runAttempt(newSessionPath); + return toResult(retry, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/pi-local/src/server/index.ts b/packages/adapters/pi-local/src/server/index.ts new file mode 100644 index 00000000..a18d5264 --- /dev/null +++ b/packages/adapters/pi-local/src/server/index.ts @@ -0,0 +1,60 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.session); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + return { + sessionId, + ...(cwd ? { cwd } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.session); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + return { + sessionId, + ...(cwd ? { cwd } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.session) + ); + }, +}; + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + listPiModels, + discoverPiModels, + discoverPiModelsCached, + ensurePiModelConfiguredAndAvailable, + resetPiModelsCacheForTests, +} from "./models.js"; +export { parsePiJsonl, isPiUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/pi-local/src/server/models.test.ts b/packages/adapters/pi-local/src/server/models.test.ts new file mode 100644 index 00000000..df777544 --- /dev/null +++ b/packages/adapters/pi-local/src/server/models.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ensurePiModelConfiguredAndAvailable, + listPiModels, + resetPiModelsCacheForTests, +} from "./models.js"; + +describe("pi models", () => { + afterEach(() => { + delete process.env.PAPERCLIP_PI_COMMAND; + resetPiModelsCacheForTests(); + }); + + it("returns an empty list when discovery command is unavailable", async () => { + process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__"; + await expect(listPiModels()).resolves.toEqual([]); + }); + + it("rejects when model is missing", async () => { + await expect( + ensurePiModelConfiguredAndAvailable({ model: "" }), + ).rejects.toThrow("Pi requires `adapterConfig.model`"); + }); + + it("rejects when discovery cannot run for configured model", async () => { + process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__"; + await expect( + ensurePiModelConfiguredAndAvailable({ + model: "xai/grok-4", + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts new file mode 100644 index 00000000..3212312a --- /dev/null +++ b/packages/adapters/pi-local/src/server/models.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { asString, runChildProcess } from "@paperclipai/adapter-utils/server-utils"; + +const MODELS_CACHE_TTL_MS = 60_000; + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelsOutput(stdout: string): AdapterModel[] { + const parsed: AdapterModel[] = []; + const lines = stdout.split(/\r?\n/); + + // Skip header line if present + let startIndex = 0; + if (lines.length > 0 && (lines[0].includes("provider") || lines[0].includes("model"))) { + startIndex = 1; + } + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + // Parse format: "provider model context max-out thinking images" + // Split by 2+ spaces to handle the columnar format + const parts = line.split(/\s{2,}/); + if (parts.length < 2) continue; + + const provider = parts[0].trim(); + const model = parts[1].trim(); + + if (!provider || !model) continue; + if (provider === "provider" && model === "model") continue; // Skip header + + const id = `${provider}/${model}`; + parsed.push({ id, label: id }); + } + + return parsed; +} + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function sortModels(models: AdapterModel[]): AdapterModel[] { + return [...models].sort((a, b) => + a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), + ); +} + +function resolvePiCommand(input: unknown): string { + const envOverride = + typeof process.env.PAPERCLIP_PI_COMMAND === "string" && + process.env.PAPERCLIP_PI_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_PI_COMMAND.trim() + : "pi"; + return asString(input, envOverride); +} + +const discoveryCache = new Map(); +const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; +const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); + +function isVolatileEnvKey(key: string): boolean { + if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; + return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +function hashValue(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function discoveryCacheKey(command: string, cwd: string, env: Record) { + const envKey = Object.entries(env) + .filter(([key]) => !isVolatileEnvKey(key)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${hashValue(value)}`) + .join("\n"); + return `${command}\n${cwd}\n${envKey}`; +} + +function pruneExpiredDiscoveryCache(now: number) { + for (const [key, value] of discoveryCache.entries()) { + if (value.expiresAt <= now) discoveryCache.delete(key); + } +} + +export async function discoverPiModels(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = resolvePiCommand(input.command); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const runtimeEnv = normalizeEnv({ ...process.env, ...env }); + + const result = await runChildProcess( + `pi-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + ["--list-models"], + { + cwd, + env: runtimeEnv, + timeoutSec: 20, + graceSec: 3, + onLog: async () => {}, + }, + ); + + if (result.timedOut) { + throw new Error("`pi --list-models` timed out."); + } + if ((result.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); + throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed."); + } + + return sortModels(dedupeModels(parseModelsOutput(result.stdout))); +} + +function normalizeEnv(input: unknown): Record { + const envInput = typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; + const env: Record = {}; + for (const [key, value] of Object.entries(envInput)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +export async function discoverPiModelsCached(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = resolvePiCommand(input.command); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const key = discoveryCacheKey(command, cwd, env); + const now = Date.now(); + pruneExpiredDiscoveryCache(now); + const cached = discoveryCache.get(key); + if (cached && cached.expiresAt > now) return cached.models; + + const models = await discoverPiModels({ command, cwd, env }); + discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); + return models; +} + +export async function ensurePiModelConfiguredAndAvailable(input: { + model?: unknown; + command?: unknown; + cwd?: unknown; + env?: unknown; +}): Promise { + const model = asString(input.model, "").trim(); + if (!model) { + throw new Error("Pi requires `adapterConfig.model` in provider/model format."); + } + + const models = await discoverPiModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + + if (models.length === 0) { + throw new Error("Pi returned no models. Run `pi --list-models` and verify provider auth."); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } + + return models; +} + +export async function listPiModels(): Promise { + try { + return await discoverPiModelsCached(); + } catch { + return []; + } +} + +export function resetPiModelsCacheForTests() { + discoveryCache.clear(); +} diff --git a/packages/adapters/pi-local/src/server/parse.test.ts b/packages/adapters/pi-local/src/server/parse.test.ts new file mode 100644 index 00000000..6a3eef4d --- /dev/null +++ b/packages/adapters/pi-local/src/server/parse.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { parsePiJsonl, isPiUnknownSessionError } from "./parse.js"; + +describe("parsePiJsonl", () => { + it("parses agent lifecycle and messages", () => { + const stdout = [ + JSON.stringify({ type: "agent_start" }), + JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: [{ type: "text", text: "Hello from Pi" }], + }, + }), + JSON.stringify({ type: "agent_end", messages: [] }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.messages).toContain("Hello from Pi"); + expect(parsed.finalMessage).toBe("Hello from Pi"); + }); + + it("parses streaming text deltas", () => { + const stdout = [ + JSON.stringify({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "Hello " }, + }), + JSON.stringify({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "World" }, + }), + JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: "Hello World", + }, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.messages).toContain("Hello World"); + }); + + it("parses tool execution", () => { + const stdout = [ + JSON.stringify({ + type: "tool_execution_start", + toolCallId: "tool_1", + toolName: "read", + args: { path: "/tmp/test.txt" }, + }), + JSON.stringify({ + type: "tool_execution_end", + toolCallId: "tool_1", + toolName: "read", + result: "file contents", + isError: false, + }), + JSON.stringify({ + type: "turn_end", + message: { role: "assistant", content: "Done" }, + toolResults: [ + { + toolCallId: "tool_1", + content: "file contents", + isError: false, + }, + ], + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.toolCalls).toHaveLength(1); + expect(parsed.toolCalls[0].toolName).toBe("read"); + expect(parsed.toolCalls[0].result).toBe("file contents"); + expect(parsed.toolCalls[0].isError).toBe(false); + }); + + it("handles errors in tool execution", () => { + const stdout = [ + JSON.stringify({ + type: "tool_execution_start", + toolCallId: "tool_1", + toolName: "read", + args: { path: "/missing.txt" }, + }), + JSON.stringify({ + type: "tool_execution_end", + toolCallId: "tool_1", + toolName: "read", + result: "File not found", + isError: true, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.toolCalls).toHaveLength(1); + expect(parsed.toolCalls[0].isError).toBe(true); + expect(parsed.toolCalls[0].result).toBe("File not found"); + }); + + it("extracts usage and cost from turn_end events", () => { + const stdout = [ + JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: "Response with usage", + usage: { + input: 100, + output: 50, + cacheRead: 20, + totalTokens: 170, + cost: { + input: 0.001, + output: 0.0015, + cacheRead: 0.0001, + cacheWrite: 0, + total: 0.0026, + }, + }, + }, + toolResults: [], + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.usage.inputTokens).toBe(100); + expect(parsed.usage.outputTokens).toBe(50); + expect(parsed.usage.cachedInputTokens).toBe(20); + expect(parsed.usage.costUsd).toBeCloseTo(0.0026, 4); + }); + + it("accumulates usage from multiple turns", () => { + const stdout = [ + JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: "First response", + usage: { + input: 50, + output: 25, + cacheRead: 0, + cost: { total: 0.001 }, + }, + }, + }), + JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: "Second response", + usage: { + input: 30, + output: 20, + cacheRead: 10, + cost: { total: 0.0015 }, + }, + }, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.usage.inputTokens).toBe(80); + expect(parsed.usage.outputTokens).toBe(45); + expect(parsed.usage.cachedInputTokens).toBe(10); + expect(parsed.usage.costUsd).toBeCloseTo(0.0025, 4); + }); + + it("handles standalone usage events with Pi format", () => { + const stdout = [ + JSON.stringify({ + type: "usage", + usage: { + input: 200, + output: 100, + cacheRead: 50, + cost: { total: 0.005 }, + }, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.usage.inputTokens).toBe(200); + expect(parsed.usage.outputTokens).toBe(100); + expect(parsed.usage.cachedInputTokens).toBe(50); + expect(parsed.usage.costUsd).toBe(0.005); + }); + + it("handles standalone usage events with generic format", () => { + const stdout = [ + JSON.stringify({ + type: "usage", + usage: { + inputTokens: 150, + outputTokens: 75, + cachedInputTokens: 25, + costUsd: 0.003, + }, + }), + ].join("\n"); + + const parsed = parsePiJsonl(stdout); + expect(parsed.usage.inputTokens).toBe(150); + expect(parsed.usage.outputTokens).toBe(75); + expect(parsed.usage.cachedInputTokens).toBe(25); + expect(parsed.usage.costUsd).toBe(0.003); + }); +}); + +describe("isPiUnknownSessionError", () => { + it("detects unknown session errors", () => { + expect(isPiUnknownSessionError("session not found: s_123", "")).toBe(true); + expect(isPiUnknownSessionError("", "unknown session id")).toBe(true); + expect(isPiUnknownSessionError("", "no session available")).toBe(true); + expect(isPiUnknownSessionError("all good", "")).toBe(false); + expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false); + }); +}); diff --git a/packages/adapters/pi-local/src/server/parse.ts b/packages/adapters/pi-local/src/server/parse.ts new file mode 100644 index 00000000..3ba50d8b --- /dev/null +++ b/packages/adapters/pi-local/src/server/parse.ts @@ -0,0 +1,211 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +interface ParsedPiOutput { + sessionId: string | null; + messages: string[]; + errors: string[]; + usage: { + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + costUsd: number; + }; + finalMessage: string | null; + toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function extractTextContent(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!) + .join(""); +} + +export function parsePiJsonl(stdout: string): ParsedPiOutput { + const result: ParsedPiOutput = { + sessionId: null, + messages: [], + errors: [], + usage: { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + costUsd: 0, + }, + finalMessage: null, + toolCalls: [], + }; + + let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const eventType = asString(event.type, ""); + + // RPC protocol messages - skip these (internal implementation detail) + if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") { + continue; + } + + // Agent lifecycle + if (eventType === "agent_start") { + continue; + } + + if (eventType === "agent_end") { + const messages = event.messages as Array> | 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 }>; + result.finalMessage = extractTextContent(content); + } + } + continue; + } + + // Turn lifecycle + if (eventType === "turn_start") { + continue; + } + + if (eventType === "turn_end") { + const message = asRecord(event.message); + if (message) { + const content = message.content as string | Array<{ type: string; text?: string }>; + const text = extractTextContent(content); + if (text) { + result.finalMessage = text; + result.messages.push(text); + } + + // Extract usage and cost from assistant message + const usage = asRecord(message.usage); + if (usage) { + result.usage.inputTokens += asNumber(usage.input, 0); + result.usage.outputTokens += asNumber(usage.output, 0); + result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0); + + // Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.) + const cost = asRecord(usage.cost); + if (cost) { + result.usage.costUsd += asNumber(cost.total, 0); + } + } + } + + // Tool results are in toolResults array + const toolResults = event.toolResults as Array> | undefined; + if (toolResults) { + for (const tr of toolResults) { + const toolCallId = asString(tr.toolCallId, ""); + const content = tr.content; + const isError = tr.isError === true; + + // Find matching tool call by toolCallId + const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId); + if (existingCall) { + existingCall.result = typeof content === "string" ? content : JSON.stringify(content); + existingCall.isError = isError; + } + } + } + continue; + } + + // Message updates (streaming) + if (eventType === "message_update") { + const assistantEvent = asRecord(event.assistantMessageEvent); + if (assistantEvent) { + const msgType = asString(assistantEvent.type, ""); + if (msgType === "text_delta") { + const delta = asString(assistantEvent.delta, ""); + if (delta) { + // Append to last message or create new + if (result.messages.length === 0) { + result.messages.push(delta); + } else { + result.messages[result.messages.length - 1] += delta; + } + } + } + } + continue; + } + + // Tool execution + if (eventType === "tool_execution_start") { + const toolCallId = asString(event.toolCallId, ""); + const toolName = asString(event.toolName, ""); + const args = event.args; + currentToolCall = { toolCallId, toolName, args }; + result.toolCalls.push({ + toolCallId, + toolName, + args, + result: null, + isError: false, + }); + continue; + } + + if (eventType === "tool_execution_end") { + const toolCallId = asString(event.toolCallId, ""); + const toolName = asString(event.toolName, ""); + const toolResult = event.result; + const isError = event.isError === true; + + // Find the tool call by toolCallId (not toolName, to handle multiple calls to same tool) + const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId); + if (existingCall) { + existingCall.result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult); + existingCall.isError = isError; + } + currentToolCall = null; + continue; + } + + // Usage tracking if available in the event (fallback for standalone usage events) + if (eventType === "usage" || event.usage) { + const usage = asRecord(event.usage); + if (usage) { + // Support both Pi format (input/output/cacheRead) and generic format (inputTokens/outputTokens/cachedInputTokens) + result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0); + result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0); + result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0); + + // Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format) + const cost = asRecord(usage.cost); + if (cost) { + result.usage.costUsd += asNumber(cost.total ?? usage.costUsd, 0); + } else { + result.usage.costUsd += asNumber(usage.costUsd, 0); + } + } + } + } + + return result; +} + +export function isPiUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\s+not\s+found|session\s+.*\s+not\s+found|no\s+session/i.test(haystack); +} diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts new file mode 100644 index 00000000..cf8fa80a --- /dev/null +++ b/packages/adapters/pi-local/src/server/test.ts @@ -0,0 +1,276 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + parseObject, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { + asStringArray, +} from "@paperclipai/adapter-utils/server-utils"; +import { discoverPiModelsCached } from "./models.js"; +import { parsePiJsonl } from "./parse.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean; +} + +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +const PI_AUTH_REQUIRED_RE = + /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|free\s+usage\s+exceeded)/i; + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "pi"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); + checks.push({ + code: "pi_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "pi_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); + + const cwdInvalid = checks.some((check) => check.code === "pi_cwd_invalid"); + if (cwdInvalid) { + checks.push({ + code: "pi_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", + detail: command, + }); + } else { + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "pi_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "pi_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + } + + const canRunProbe = + checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable"); + + if (canRunProbe) { + try { + const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "pi_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from Pi.`, + }); + } else { + checks.push({ + code: "pi_models_empty", + level: "warn", + message: "Pi returned no models.", + hint: "Run `pi --list-models` and verify provider authentication.", + }); + } + } catch (err) { + checks.push({ + code: "pi_models_discovery_failed", + level: "warn", + message: err instanceof Error ? err.message : "Pi model discovery failed.", + hint: "Run `pi --list-models` manually to verify provider auth and config.", + }); + } + } + + const configuredModel = asString(config.model, "").trim(); + if (!configuredModel) { + checks.push({ + code: "pi_model_required", + level: "error", + message: "Pi requires a configured model in provider/model format.", + hint: "Set adapterConfig.model using an ID from `pi --list-models`.", + }); + } else if (canRunProbe) { + // Verify model is in the list + try { + const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv }); + const modelExists = discovered.some((m: { id: string }) => m.id === configuredModel); + if (modelExists) { + checks.push({ + code: "pi_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + } else { + checks.push({ + code: "pi_model_not_found", + level: "warn", + message: `Configured model "${configuredModel}" not found in available models.`, + hint: "Run `pi --list-models` and choose a currently available provider/model ID.", + }); + } + } catch { + // If we can't verify, just note it + checks.push({ + code: "pi_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + } + } + + if (canRunProbe && configuredModel) { + // Parse model for probe + const provider = configuredModel.includes("/") + ? configuredModel.slice(0, configuredModel.indexOf("/")) + : ""; + const modelId = configuredModel.includes("/") + ? configuredModel.slice(configuredModel.indexOf("/") + 1) + : configuredModel; + const thinking = asString(config.thinking, "").trim(); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["-p", "Respond with hello.", "--mode", "json"]; + if (provider) args.push("--provider", provider); + if (modelId) args.push("--model", modelId); + if (thinking) args.push("--thinking", thinking); + args.push("--tools", "read"); + if (extraArgs.length > 0) args.push(...extraArgs); + + try { + const probe = await runChildProcess( + `pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + timeoutSec: 60, + graceSec: 5, + onLog: async () => {}, + }, + ); + + const parsed = parsePiJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errors[0] ?? null); + const authEvidence = `${parsed.errors.join("\n")}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "pi_hello_probe_timed_out", + level: "warn", + message: "Pi hello probe timed out.", + hint: "Retry the probe. If this persists, run Pi manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && parsed.errors.length === 0) { + const summary = (parsed.finalMessage || parsed.messages.join(" ")).trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "pi_hello_probe_passed" : "pi_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Pi hello probe succeeded." + : "Pi probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (PI_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "pi_hello_probe_auth_required", + level: "warn", + message: "Pi is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Set provider API key environment variable (e.g., ANTHROPIC_API_KEY, XAI_API_KEY) and retry.", + }); + } else { + checks.push({ + code: "pi_hello_probe_failed", + level: "error", + message: "Pi hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `pi --mode json` manually in this working directory to debug.", + }); + } + } catch (err) { + checks.push({ + code: "pi_hello_probe_failed", + level: "error", + message: "Pi hello probe failed.", + detail: err instanceof Error ? err.message : String(err), + hint: "Run `pi --mode json` manually in this working directory to debug.", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/pi-local/src/ui/build-config.ts b/packages/adapters/pi-local/src/ui/build-config.ts new file mode 100644 index 00000000..f871019d --- /dev/null +++ b/packages/adapters/pi-local/src/ui/build-config.ts @@ -0,0 +1,71 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildPiLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.model) ac.model = v.model; + if (v.thinkingEffort) ac.thinking = v.thinkingEffort; + + // Pi sessions can run until the CLI exits naturally; keep timeout disabled (0) + ac.timeoutSec = 0; + ac.graceSec = 20; + + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = v.extraArgs; + if (v.args) ac.args = v.args; + + return ac; +} diff --git a/packages/adapters/pi-local/src/ui/index.ts b/packages/adapters/pi-local/src/ui/index.ts new file mode 100644 index 00000000..89b781a7 --- /dev/null +++ b/packages/adapters/pi-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parsePiStdoutLine } from "./parse-stdout.js"; +export { buildPiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/pi-local/src/ui/parse-stdout.ts b/packages/adapters/pi-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..b80fe5f1 --- /dev/null +++ b/packages/adapters/pi-local/src/ui/parse-stdout.ts @@ -0,0 +1,147 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function extractTextContent(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!) + .join(""); +} + +export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + // RPC protocol messages - filter these out (internal implementation detail) + if (type === "response" || type === "extension_ui_request" || type === "extension_ui_response" || type === "extension_error") { + return []; + } + + // Agent lifecycle + if (type === "agent_start") { + return [{ kind: "system", ts, text: "Pi agent started" }]; + } + + if (type === "agent_end") { + return [{ kind: "system", ts, text: "Pi agent finished" }]; + } + + // Turn lifecycle + if (type === "turn_start") { + return [{ kind: "system", ts, text: "Turn started" }]; + } + + if (type === "turn_end") { + const message = asRecord(parsed.message); + const toolResults = parsed.toolResults as Array> | undefined; + + const entries: TranscriptEntry[] = []; + + if (message) { + const content = message.content as string | Array<{ type: string; text?: string }>; + const text = extractTextContent(content); + if (text) { + entries.push({ kind: "assistant", ts, text }); + } + } + + // Process tool results + if (toolResults) { + for (const tr of toolResults) { + const content = tr.content; + const isError = tr.isError === true; + const contentStr = typeof content === "string" ? content : JSON.stringify(content); + entries.push({ + kind: "tool_result", + ts, + toolUseId: asString(tr.toolCallId, "unknown"), + content: contentStr, + isError, + }); + } + } + + return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }]; + } + + // Message streaming + if (type === "message_start") { + return []; + } + + if (type === "message_update") { + const assistantEvent = asRecord(parsed.assistantMessageEvent); + if (assistantEvent) { + const msgType = asString(assistantEvent.type); + if (msgType === "text_delta") { + const delta = asString(assistantEvent.delta); + if (delta) { + return [{ kind: "assistant", ts, text: delta, delta: true }]; + } + } + } + return []; + } + + if (type === "message_end") { + return []; + } + + // Tool execution + if (type === "tool_execution_start") { + const toolName = asString(parsed.toolName); + const args = parsed.args; + if (toolName) { + return [{ + kind: "tool_call", + ts, + name: toolName, + input: args, + }]; + } + return [{ kind: "system", ts, text: `Tool started` }]; + } + + if (type === "tool_execution_update") { + return []; + } + + if (type === "tool_execution_end") { + const toolCallId = asString(parsed.toolCallId); + const result = parsed.result; + const isError = parsed.isError === true; + const contentStr = typeof result === "string" ? result : JSON.stringify(result); + + return [{ + kind: "tool_result", + ts, + toolUseId: toolCallId || "unknown", + content: contentStr, + isError, + }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/pi-local/tsconfig.json b/packages/adapters/pi-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/pi-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/pi-local/vitest.config.ts b/packages/adapters/pi-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/pi-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json index 845d5487..0a0b4521 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,6 +38,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/node": "^24.6.0", "drizzle-kit": "^0.31.9", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 93edb2a5..8fa979d2 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -2,12 +2,13 @@ import { createHash } from "node:crypto"; import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; import { readFile, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import postgres from "postgres"; import * as schema from "./schema/index.js"; -const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname; +const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)); const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; -const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname; +const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url)); function isSafeIdentifier(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); @@ -702,8 +703,7 @@ export async function migratePostgresIfEmpty(url: string): Promise statement-breakpoint +CREATE INDEX "issue_comments_company_author_issue_created_at_idx" ON "issue_comments" USING btree ("company_id","author_user_id","issue_id","created_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/0025_nasty_salo.sql b/packages/db/src/migrations/0025_nasty_salo.sql new file mode 100644 index 00000000..c44efa47 --- /dev/null +++ b/packages/db/src/migrations/0025_nasty_salo.sql @@ -0,0 +1,15 @@ +CREATE TABLE "issue_read_states" ( + "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, + "last_read_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 +ALTER TABLE "issue_read_states" ADD CONSTRAINT "issue_read_states_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_read_states" ADD CONSTRAINT "issue_read_states_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_read_states_company_issue_idx" ON "issue_read_states" USING btree ("company_id","issue_id");--> statement-breakpoint +CREATE INDEX "issue_read_states_company_user_idx" ON "issue_read_states" USING btree ("company_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_read_states_company_issue_user_idx" ON "issue_read_states" USING btree ("company_id","issue_id","user_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0024_snapshot.json b/packages/db/src/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000..fef87b7d --- /dev/null +++ b/packages/db/src/migrations/meta/0024_snapshot.json @@ -0,0 +1,5693 @@ +{ + "id": "beafd21e-2453-4df4-9148-630e9dd62532", + "prevId": "10b08f4e-4837-488c-a3ed-0d66a97d7257", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0025_snapshot.json b/packages/db/src/migrations/meta/0025_snapshot.json new file mode 100644 index 00000000..a395dd21 --- /dev/null +++ b/packages/db/src/migrations/meta/0025_snapshot.json @@ -0,0 +1,5849 @@ +{ + "id": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82", + "prevId": "beafd21e-2453-4df4-9148-630e9dd62532", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e55f2127..c3e25050 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -169,6 +169,20 @@ "when": 1772139727599, "tag": "0023_fair_lethal_legion", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1772806603601, + "tag": "0024_far_beast", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1772807461603, + "tag": "0025_nasty_salo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c6c368b8..eb12c064 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -20,6 +20,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; export { issueAttachments } from "./issue_attachments.js"; export { heartbeatRuns } from "./heartbeat_runs.js"; diff --git a/packages/db/src/schema/issue_comments.ts b/packages/db/src/schema/issue_comments.ts index 7c2f644f..fcefb830 100644 --- a/packages/db/src/schema/issue_comments.ts +++ b/packages/db/src/schema/issue_comments.ts @@ -18,5 +18,16 @@ export const issueComments = pgTable( (table) => ({ issueIdx: index("issue_comments_issue_idx").on(table.issueId), companyIdx: index("issue_comments_company_idx").on(table.companyId), + companyIssueCreatedAtIdx: index("issue_comments_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + companyAuthorIssueCreatedAtIdx: index("issue_comments_company_author_issue_created_at_idx").on( + table.companyId, + table.authorUserId, + table.issueId, + table.createdAt, + ), }), ); diff --git a/packages/db/src/schema/issue_read_states.ts b/packages/db/src/schema/issue_read_states.ts new file mode 100644 index 00000000..8423eee5 --- /dev/null +++ b/packages/db/src/schema/issue_read_states.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const issueReadStates = pgTable( + "issue_read_states", + { + 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(), + lastReadAt: timestamp("last_read_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_read_states_company_issue_idx").on(table.companyId, table.issueId), + companyUserIdx: index("issue_read_states_company_user_idx").on(table.companyId, table.userId), + companyIssueUserUnique: uniqueIndex("issue_read_states_company_issue_user_idx").on( + table.companyId, + table.issueId, + table.userId, + ), + }), +); diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 37903a90..258131bf 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -55,6 +55,7 @@ export const serverConfigSchema = z.object({ export const authConfigSchema = z.object({ baseUrlMode: z.enum(AUTH_BASE_URL_MODES).default("auto"), publicBaseUrl: z.string().url().optional(), + disableSignUp: z.boolean().default(false), }); export const storageLocalDiskConfigSchema = z.object({ @@ -103,6 +104,7 @@ export const paperclipConfigSchema = z server: serverConfigSchema, auth: authConfigSchema.default({ baseUrlMode: "auto", + disableSignUp: false, }), storage: storageConfigSchema.default({ provider: "local_disk", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 02e83817..ba75dc8e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,7 +21,16 @@ export const AGENT_STATUSES = [ ] as const; export type AgentStatus = (typeof AGENT_STATUSES)[number]; -export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const; +export const AGENT_ADAPTER_TYPES = [ + "process", + "http", + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "cursor", + "openclaw_gateway", +] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; export const AGENT_ROLES = [ @@ -39,6 +48,20 @@ export const AGENT_ROLES = [ ] as const; export type AgentRole = (typeof AGENT_ROLES)[number]; +export const AGENT_ROLE_LABELS: Record = { + ceo: "CEO", + cto: "CTO", + cmo: "CMO", + cfo: "CFO", + engineer: "Engineer", + designer: "Designer", + pm: "PM", + qa: "QA", + devops: "DevOps", + researcher: "Researcher", + general: "General", +}; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ec9eb6..1a594cb1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export { AGENT_STATUSES, AGENT_ADAPTER_TYPES, AGENT_ROLES, + AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, ISSUE_PRIORITIES, @@ -197,6 +198,7 @@ export { updateBudgetSchema, createAssetImageMetadataSchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, @@ -206,6 +208,7 @@ export { type UpdateBudget, type CreateAssetImageMetadata, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f5adeb95..1ff7d7c8 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -82,6 +82,9 @@ export interface Issue { project?: Project | null; goal?: Goal | null; mentionedProjects?: Project[]; + myLastTouchAt?: Date | null; + lastExternalCommentAt?: Date | null; + isUnreadForMe?: boolean; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 494c6842..75b31709 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -9,18 +9,32 @@ import { export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), - expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), + agentMessage: z.string().max(4000).optional().nullable(), }); export type CreateCompanyInvite = z.infer; +export const createOpenClawInvitePromptSchema = z.object({ + agentMessage: z.string().max(4000).optional().nullable(), +}); + +export type CreateOpenClawInvitePrompt = z.infer< + typeof createOpenClawInvitePromptSchema +>; + export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(), capabilities: z.string().max(4000).optional().nullable(), agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), + // OpenClaw join compatibility fields accepted at top level. + responsesWebhookUrl: z.string().max(4000).optional().nullable(), + responsesWebhookMethod: z.string().max(32).optional().nullable(), + responsesWebhookHeaders: z.record(z.string(), z.unknown()).optional().nullable(), + paperclipApiUrl: z.string().max(4000).optional().nullable(), + webhookAuthHeader: z.string().max(4000).optional().nullable(), }); export type AcceptInvite = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 12ad7ffb..f4130c67 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,12 +119,14 @@ export { export { createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85f9d4b2..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,15 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local + '@paperclipai/adapter-pi-local': + specifier: workspace:* + version: link:../packages/adapters/pi-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -78,6 +84,9 @@ importers: packages/adapter-utils: devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -91,6 +100,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -104,6 +116,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -117,6 +132,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -130,6 +148,31 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/adapters/openclaw-gateway: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -143,6 +186,25 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/adapters/pi-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -159,6 +221,9 @@ importers: specifier: ^3.4.5 version: 3.4.8 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -170,7 +235,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -199,9 +264,15 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local + '@paperclipai/adapter-pi-local': + specifier: workspace:* + version: link:../packages/adapters/pi-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -213,7 +284,7 @@ importers: version: link:../packages/shared better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@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))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@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))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -260,9 +331,15 @@ importers: '@types/multer': specifier: ^2.0.0 version: 2.0.0 + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -274,10 +351,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -305,9 +382,15 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-openclaw-gateway': + specifier: workspace:* + version: link:../packages/adapters/openclaw-gateway '@paperclipai/adapter-opencode-local': specifier: workspace:* version: link:../packages/adapters/opencode-local + '@paperclipai/adapter-pi-local': + specifier: workspace:* + version: link:../packages/adapters/pi-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -335,6 +418,9 @@ importers: lucide-react: specifier: ^0.574.0 version: 0.574.0(react@19.2.4) + mermaid: + specifier: ^11.12.0 + version: 11.12.3 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -387,6 +473,9 @@ importers: packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -662,6 +751,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -717,6 +809,21 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + '@clack/core@0.4.2': resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} @@ -1377,6 +1484,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1547,6 +1660,9 @@ packages: react: '>= 18 || >= 19' react-dom: '>= 18 || >= 19' + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -2774,6 +2890,99 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2792,6 +3001,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2816,6 +3028,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -2845,12 +3060,18 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3112,6 +3333,14 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3156,6 +3385,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3166,6 +3403,9 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -3192,6 +3432,12 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3207,13 +3453,172 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3245,6 +3650,9 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3287,6 +3695,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3646,6 +4058,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3678,6 +4093,10 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3695,6 +4114,13 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. @@ -3804,6 +4230,13 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + katex@0.16.37: + resolution: {integrity: sha512-TIGjO2cCGYono+uUzgkE7RFF329mLLWGuHUlSr6cwIVj9O8f0VQZ783rsanmJpFUo32vvtj7XT04NGRPh+SZFg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -3812,6 +4245,16 @@ packages: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lexical@0.35.0: resolution: {integrity: sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==} @@ -3894,6 +4337,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -3925,6 +4371,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4002,6 +4453,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -4152,6 +4606,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -4234,6 +4691,9 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4241,6 +4701,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4332,6 +4795,15 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -4545,6 +5017,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4553,6 +5028,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4564,6 +5042,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4725,6 +5206,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -4759,6 +5243,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4789,6 +5277,10 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4816,6 +5308,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4888,6 +5383,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -5016,6 +5515,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5070,6 +5589,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5698,6 +6222,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.2': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -5841,6 +6367,23 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + '@clack/core@0.4.2': dependencies: picocolors: 1.1.1 @@ -6457,6 +7000,14 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + '@inquirer/external-editor@1.0.3(@types/node@25.2.3)': dependencies: chardet: 2.1.1 @@ -6813,6 +7364,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + '@noble/ciphers@2.1.1': {} '@noble/hashes@1.8.0': {} @@ -8145,6 +8700,123 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -8170,6 +8842,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8194,6 +8868,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -8231,10 +8909,17 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.3 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': @@ -8257,6 +8942,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -8340,7 +9033,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@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))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@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))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8360,7 +9053,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -8456,6 +9149,20 @@ snapshots: check-error@2.1.3: {} + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -8505,6 +9212,10 @@ snapshots: commander@13.1.0: {} + commander@7.2.0: {} + + commander@8.3.0: {} + component-emitter@1.3.1: {} compute-scroll-into-view@2.0.4: {} @@ -8516,6 +9227,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + confbox@0.1.8: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -8530,6 +9243,14 @@ snapshots: cookiejar@2.1.4: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -8542,13 +9263,199 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + d@1.0.2: dependencies: es5-ext: 0.10.64 type: 2.7.3 + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + dateformat@4.6.3: {} + dayjs@1.11.19: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -8570,6 +9477,10 @@ snapshots: defu@6.1.4: {} + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -8601,6 +9512,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -9007,6 +9922,8 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -9055,6 +9972,10 @@ snapshots: human-id@4.1.3: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -9067,6 +9988,10 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@1.0.1: {} + + internmap@2.0.3: {} + intersection-observer@0.10.0: {} ipaddr.js@1.9.1: {} @@ -9143,10 +10068,28 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + katex@0.16.37: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + kleur@4.1.5: {} kysely@0.28.11: {} + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + lexical@0.35.0: {} lib0@0.2.117: @@ -9206,6 +10149,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash-es@4.17.23: {} + lodash.startcase@4.4.0: {} longest-streak@3.1.0: {} @@ -9232,6 +10177,8 @@ snapshots: markdown-table@3.0.4: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: @@ -9434,6 +10381,29 @@ snapshots: merge2@1.4.1: {} + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.2 + katex: 0.16.37 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + methods@1.1.2: {} micromark-core-commonmark@2.0.3: @@ -9751,6 +10721,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + mri@1.2.0: {} ms@2.1.3: {} @@ -9822,6 +10799,8 @@ snapshots: dependencies: quansync: 0.2.11 + package-manager-detector@1.6.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9834,6 +10813,8 @@ snapshots: parseurl@1.3.3: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9936,6 +10917,19 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -10207,6 +11201,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.2: {} + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -10240,6 +11236,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -10256,6 +11259,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -10419,6 +11424,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -10459,6 +11466,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10480,6 +11489,8 @@ snapshots: trough@2.2.0: {} + ts-dedent@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -10506,6 +11517,8 @@ snapshots: typescript@5.9.3: {} + ufo@1.6.3: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10582,6 +11595,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + uvu@0.5.6: dependencies: dequal: 2.0.3 @@ -10601,6 +11616,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -10622,6 +11658,21 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -10637,6 +11688,21 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -10652,6 +11718,48 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.12.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 @@ -10694,6 +11802,23 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} which@2.0.2: diff --git a/releases/.gitkeep b/releases/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/releases/v0.2.7.md b/releases/v0.2.7.md new file mode 100644 index 00000000..b086d78e --- /dev/null +++ b/releases/v0.2.7.md @@ -0,0 +1,15 @@ +# v0.2.7 + +> Released: 2026-03-04 + +## Improvements + +- **Onboarding resilience** — The setup wizard now continues after a failed environment test instead of getting stuck. If your Anthropic API key doesn't work, you can retry or clear it and proceed with a different configuration. +- **Docker onboarding flow** — Cleaner defaults for the Docker smoke test and improved console guidance during `npx` onboarding runs. +- **Issue search in skills** — The Paperclip skill reference now documents the `q=` search parameter for finding issues by keyword. + +## Fixes + +- **Markdown list rendering** — Fixed list markers (`-`, `*`) not rendering correctly in the editor and comment views. +- **Archived companies hidden** — The new issue dialog no longer shows archived companies in the company selector. +- **Embedded Postgres requirement** — The server now correctly requires the `embedded-postgres` dependency when running in embedded DB mode, preventing startup failures. diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 71039492..43262fd6 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -47,7 +47,7 @@ const serverScript = mode === "watch" ? "dev:watch" : "dev"; const child = spawn( pnpmBin, ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], - { stdio: "inherit", env }, + { stdio: "inherit", env, shell: process.platform === "win32" }, ); child.on("exit", (code, signal) => { diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 705c58d1..c18bce72 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -32,7 +32,8 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", + "packages/adapters/opencode-local", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that are NOT bundled and must stay as npm dependencies. diff --git a/scripts/release.sh b/scripts/release.sh index ab4d8fe2..6827e0fa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,12 +4,16 @@ set -euo pipefail # release.sh — One-command version bump, build, and publish via Changesets. # # Usage: -# ./scripts/release.sh patch # 0.2.0 → 0.2.1 -# ./scripts/release.sh minor # 0.2.0 → 0.3.0 -# ./scripts/release.sh major # 0.2.0 → 1.0.0 -# ./scripts/release.sh patch --dry-run # everything except npm publish +# ./scripts/release.sh patch # 0.2.0 → 0.2.1 +# ./scripts/release.sh minor # 0.2.0 → 0.3.0 +# ./scripts/release.sh major # 0.2.0 → 1.0.0 +# ./scripts/release.sh patch --dry-run # everything except npm publish +# ./scripts/release.sh patch --canary # publish under @canary tag, no commit/tag +# ./scripts/release.sh patch --canary --dry-run +# ./scripts/release.sh --promote 0.2.8 # promote canary to @latest + commit/tag +# ./scripts/release.sh --promote 0.2.8 --dry-run # -# Steps: +# Steps (normal): # 1. Preflight checks (clean tree, npm login) # 2. Auto-create a changeset for all public packages # 3. Run changeset version (bumps versions, generates CHANGELOGs) @@ -17,30 +21,173 @@ set -euo pipefail # 5. Build CLI bundle (esbuild) # 6. Publish to npm via changeset publish (unless --dry-run) # 7. Commit and tag +# +# --canary: Steps 1-5 unchanged, Step 6 publishes with --tag canary, Step 7 skipped. +# --promote: Skips Steps 1-6, promotes canary to latest, then commits and tags. REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" +# ── Helper: create GitHub Release ──────────────────────────────────────────── +create_github_release() { + local version="$1" + local is_dry_run="$2" + local release_notes="$REPO_ROOT/releases/v${version}.md" + + if [ "$is_dry_run" = true ]; then + echo " [dry-run] gh release create v$version" + return + fi + + if ! command -v gh &>/dev/null; then + echo " ⚠ gh CLI not found — skipping GitHub Release" + return + fi + + local gh_args=(gh release create "v$version" --title "v$version") + if [ -f "$release_notes" ]; then + gh_args+=(--notes-file "$release_notes") + else + gh_args+=(--generate-notes) + fi + + if "${gh_args[@]}"; then + echo " ✓ Created GitHub Release v$version" + else + echo " ⚠ GitHub Release creation failed (non-fatal)" + fi +} + # ── Parse args ──────────────────────────────────────────────────────────────── dry_run=false +canary=false +promote=false +promote_version="" bump_type="" -for arg in "$@"; do - case "$arg" in +while [ $# -gt 0 ]; do + case "$1" in --dry-run) dry_run=true ;; - *) bump_type="$arg" ;; + --canary) canary=true ;; + --promote) + promote=true + shift + if [ $# -eq 0 ] || [[ "$1" == --* ]]; then + echo "Error: --promote requires a version argument (e.g. --promote 0.2.8)" + exit 1 + fi + promote_version="$1" + ;; + *) bump_type="$1" ;; esac + shift done -if [ -z "$bump_type" ]; then - echo "Usage: $0 [--dry-run]" +if [ "$promote" = true ] && [ "$canary" = true ]; then + echo "Error: --canary and --promote cannot be used together" exit 1 fi -if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then - echo "Error: bump type must be patch, minor, or major (got '$bump_type')" - exit 1 +if [ "$promote" = false ]; then + if [ -z "$bump_type" ]; then + echo "Usage: $0 [--dry-run] [--canary]" + echo " $0 --promote [--dry-run]" + exit 1 + fi + + if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + echo "Error: bump type must be patch, minor, or major (got '$bump_type')" + exit 1 + fi +fi + +# ── Promote mode (skips Steps 1-6) ─────────────────────────────────────────── + +if [ "$promote" = true ]; then + NEW_VERSION="$promote_version" + echo "" + echo "==> Promote mode: promoting v$NEW_VERSION from canary to latest..." + + # Get all publishable package names + PACKAGES=$(node -e " +const { readFileSync } = require('fs'); +const { resolve } = require('path'); +const root = '$REPO_ROOT'; +const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', + 'server', 'cli']; +const names = []; +for (const d of dirs) { + try { + const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); + if (!pkg.private) names.push(pkg.name); + } catch {} +} +console.log(names.join('\n')); +") + + echo "" + echo " Promoting packages to @latest:" + while IFS= read -r pkg; do + if [ "$dry_run" = true ]; then + echo " [dry-run] npm dist-tag add ${pkg}@${NEW_VERSION} latest" + else + npm dist-tag add "${pkg}@${NEW_VERSION}" latest + echo " ✓ ${pkg}@${NEW_VERSION} → latest" + fi + done <<< "$PACKAGES" + + # Restore CLI dev package.json if present + if [ -f "$CLI_DIR/package.dev.json" ]; then + mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" + echo " ✓ Restored workspace dependencies in cli/package.json" + fi + + # Remove the README copied for npm publishing + if [ -f "$CLI_DIR/README.md" ]; then + rm "$CLI_DIR/README.md" + fi + + # Remove temporary build artifacts + rm -rf "$REPO_ROOT/server/ui-dist" + for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do + rm -rf "$REPO_ROOT/$pkg_dir/skills" + done + + # Stage release files, commit, and tag + echo "" + echo " Committing and tagging v$NEW_VERSION..." + if [ "$dry_run" = true ]; then + echo " [dry-run] git add + commit + tag v$NEW_VERSION" + else + git add \ + .changeset/ \ + '**/CHANGELOG.md' \ + '**/package.json' \ + cli/src/index.ts + git commit -m "chore: release v$NEW_VERSION" + git tag "v$NEW_VERSION" + echo " ✓ Committed and tagged v$NEW_VERSION" + fi + + create_github_release "$NEW_VERSION" "$dry_run" + + echo "" + if [ "$dry_run" = true ]; then + echo "Dry run complete for promote v$NEW_VERSION." + echo " - Would promote all packages to @latest" + echo " - Would commit and tag v$NEW_VERSION" + echo " - Would create GitHub Release" + else + echo "Promoted all packages to @latest at v$NEW_VERSION" + echo "" + echo "Verify: npm view paperclipai@latest version" + echo "" + echo "To push:" + echo " git push && git push origin v$NEW_VERSION" + fi + exit 0 fi # ── Step 1: Preflight checks ───────────────────────────────────────────────── @@ -74,7 +221,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -131,7 +278,8 @@ pnpm --filter @paperclipai/adapter-utils build pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build -pnpm --filter @paperclipai/adapter-openclaw build +pnpm --filter @paperclipai/adapter-opencode-local build +pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving @@ -158,29 +306,48 @@ echo " ✓ CLI bundled" if [ "$dry_run" = true ]; then echo "" - echo "==> Step 6/7: Skipping publish (--dry-run)" + if [ "$canary" = true ]; then + echo "==> Step 6/7: Skipping publish (--dry-run, --canary)" + else + echo "==> Step 6/7: Skipping publish (--dry-run)" + fi echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" npm pack --dry-run 2>&1 | tail -3 done cd "$REPO_ROOT" + if [ "$canary" = true ]; then + echo "" + echo " [dry-run] Would publish with: npx changeset publish --tag canary" + fi else echo "" - echo "==> Step 6/7: Publishing to npm..." - cd "$REPO_ROOT" - npx changeset publish - echo " ✓ Published all packages" + if [ "$canary" = true ]; then + echo "==> Step 6/7: Publishing to npm (canary)..." + cd "$REPO_ROOT" + npx changeset publish --tag canary + echo " ✓ Published all packages under @canary tag" + else + echo "==> Step 6/7: Publishing to npm..." + cd "$REPO_ROOT" + npx changeset publish + echo " ✓ Published all packages" + fi fi # ── Step 7: Restore CLI dev package.json and commit ────────────────────────── echo "" -echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..." +if [ "$canary" = true ]; then + echo "==> Step 7/7: Skipping commit and tag (canary mode — promote later)..." +else + echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..." +fi cd "$REPO_ROOT" # Restore the dev package.json (build-npm.sh backs it up) @@ -200,24 +367,48 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc rm -rf "$REPO_ROOT/$pkg_dir/skills" done -# Stage only release-related files (avoid sweeping unrelated changes with -A) -git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts -git commit -m "chore: release v$NEW_VERSION" -git tag "v$NEW_VERSION" -echo " ✓ Committed and tagged v$NEW_VERSION" +if [ "$canary" = false ]; then + # Stage only release-related files (avoid sweeping unrelated changes with -A) + git add \ + .changeset/ \ + '**/CHANGELOG.md' \ + '**/package.json' \ + cli/src/index.ts + git commit -m "chore: release v$NEW_VERSION" + git tag "v$NEW_VERSION" + echo " ✓ Committed and tagged v$NEW_VERSION" +fi + +if [ "$canary" = false ]; then + create_github_release "$NEW_VERSION" "$dry_run" +fi # ── Done ────────────────────────────────────────────────────────────────────── echo "" -if [ "$dry_run" = true ]; then +if [ "$canary" = true ]; then + if [ "$dry_run" = true ]; then + echo "Dry run complete for canary v$NEW_VERSION." + echo " - Versions bumped, built, and previewed" + echo " - Dev package.json restored" + echo " - No commit or tag (canary mode)" + echo "" + echo "To actually publish canary, run:" + echo " ./scripts/release.sh $bump_type --canary" + else + echo "Published canary at v$NEW_VERSION" + echo "" + echo "Verify: npm view paperclipai@canary version" + echo "" + echo "To promote to latest:" + echo " ./scripts/release.sh --promote $NEW_VERSION" + fi +elif [ "$dry_run" = true ]; then echo "Dry run complete for v$NEW_VERSION." echo " - Versions bumped, built, and previewed" echo " - Dev package.json restored" echo " - Commit and tag created (locally)" + echo " - Would create GitHub Release" echo "" echo "To actually publish, run:" echo " ./scripts/release.sh $bump_type" @@ -226,4 +417,6 @@ else echo "" echo "To push:" echo " git push && git push origin v$NEW_VERSION" + echo "" + echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION" fi diff --git a/scripts/smoke/openclaw-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index d29c835d..0ce522e9 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -15,6 +15,39 @@ require_cmd() { command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" } +reset_openclaw_state_dir() { + local state_dir="$1" + local resolved_state_dir resolved_home + + [[ -n "$state_dir" ]] || fail "OPENCLAW_CONFIG_DIR must not be empty when resetting state" + mkdir -p "$state_dir" + + resolved_state_dir="$(cd "$state_dir" && pwd -P)" + resolved_home="$(cd "$HOME" && pwd -P)" + case "$resolved_state_dir" in + "/"|"$resolved_home") + fail "refusing to reset unsafe OPENCLAW_CONFIG_DIR: $resolved_state_dir" + ;; + esac + + log "resetting OpenClaw state under $resolved_state_dir" + rm -rf \ + "$resolved_state_dir/agents" \ + "$resolved_state_dir/canvas" \ + "$resolved_state_dir/cron" \ + "$resolved_state_dir/credentials" \ + "$resolved_state_dir/devices" \ + "$resolved_state_dir/identity" \ + "$resolved_state_dir/logs" \ + "$resolved_state_dir/memory" \ + "$resolved_state_dir/skills" \ + "$resolved_state_dir/workspace" + rm -f \ + "$resolved_state_dir/openclaw.json" \ + "$resolved_state_dir/openclaw.json.bak" \ + "$resolved_state_dir/update-check.json" +} + require_cmd docker require_cmd git require_cmd curl @@ -23,8 +56,12 @@ require_cmd grep OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}" OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" -OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$OPENCLAW_TMP_DIR/openclaw-paperclip-smoke}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$OPENCLAW_CONFIG_DIR/workspace}" OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" @@ -34,7 +71,13 @@ OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}" OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-45}" OPENCLAW_OPEN_BROWSER="${OPENCLAW_OPEN_BROWSER:-0}" OPENCLAW_SECRETS_FILE="${OPENCLAW_SECRETS_FILE:-$HOME/.secrets}" +# Keep default one-command UX: local smoke run should not require manual pairing. OPENCLAW_DISABLE_DEVICE_AUTH="${OPENCLAW_DISABLE_DEVICE_AUTH:-1}" +OPENCLAW_MODEL_PRIMARY="${OPENCLAW_MODEL_PRIMARY:-openai/gpt-5.2}" +OPENCLAW_MODEL_FALLBACK="${OPENCLAW_MODEL_FALLBACK:-openai/gpt-5.2-chat-latest}" +OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}" +PAPERCLIP_HOST_PORT="${PAPERCLIP_HOST_PORT:-3100}" +PAPERCLIP_HOST_FROM_CONTAINER="${PAPERCLIP_HOST_FROM_CONTAINER:-host.docker.internal}" case "$OPENCLAW_DISABLE_DEVICE_AUTH" in 1|true|TRUE|True|yes|YES|Yes) @@ -59,20 +102,42 @@ fi log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR" if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then - git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet origin || true - git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet main || true - git -C "$OPENCLAW_DOCKER_DIR" pull --ff-only --quiet origin main || true + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true else rm -rf "$OPENCLAW_DOCKER_DIR" git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR" + git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true fi +resolved_openclaw_ref="" +if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF" +elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then + resolved_openclaw_ref="$OPENCLAW_REPO_REF" +fi +[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR" +git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref" +log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))" + if [[ "$OPENCLAW_BUILD" == "1" ]]; then log "building Docker image $OPENCLAW_IMAGE" docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR" fi log "writing OpenClaw config under $OPENCLAW_CONFIG_DIR" +if [[ "$OPENCLAW_RESET_STATE" == "1" ]]; then + # Ensure deterministic smoke behavior across reruns by restarting with a clean state dir. + OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" \ + OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \ + OPENCLAW_GATEWAY_PORT="$OPENCLAW_GATEWAY_PORT" \ + OPENCLAW_BRIDGE_PORT="$OPENCLAW_BRIDGE_PORT" \ + OPENCLAW_GATEWAY_BIND="$OPENCLAW_GATEWAY_BIND" \ + OPENCLAW_GATEWAY_TOKEN="$OPENCLAW_GATEWAY_TOKEN" \ + OPENCLAW_IMAGE="$OPENCLAW_IMAGE" \ + OPENAI_API_KEY="$OPENAI_API_KEY" \ + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true + reset_openclaw_state_dir "$OPENCLAW_CONFIG_DIR" +fi mkdir -p "$OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_CONFIG_DIR/identity" "$OPENCLAW_CONFIG_DIR/credentials" chmod 700 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR/credentials" @@ -100,6 +165,12 @@ cat > "$OPENCLAW_CONFIG_DIR/openclaw.json" <process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then + echo "http://${candidate}:${PAPERCLIP_HOST_PORT}" + return 0 + fi + done + return 1 +} + log "starting OpenClaw gateway container" compose up -d openclaw-gateway @@ -154,6 +241,7 @@ if [[ "$READY" != "1" ]]; then fail "gateway did not become healthy in ${OPENCLAW_WAIT_SECONDS}s" fi +paperclip_base_url="$(detect_paperclip_base_url || true)" dashboard_output="$(compose run --rm openclaw-cli dashboard --no-open)" dashboard_url="$(grep -Eo 'https?://[^[:space:]]+#token=[^[:space:]]+' <<<"$dashboard_output" | head -n1 || true)" if [[ -z "$dashboard_url" ]]; then @@ -166,15 +254,35 @@ OpenClaw gateway is running. Dashboard URL: $dashboard_url - -Pairing mode: - OPENCLAW_DISABLE_DEVICE_AUTH=$OPENCLAW_DISABLE_DEVICE_AUTH EOF if [[ "$OPENCLAW_DISABLE_DEVICE_AUTH_JSON" == "true" ]]; then cat <&2 +} + +fail() { + echo "[openclaw-gateway-e2e] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +} + +require_cmd curl +require_cmd jq +require_cmd docker +require_cmd node +require_cmd shasum + +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}" +API_BASE="${PAPERCLIP_API_URL%/}/api" + +COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}" +OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}" +OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}" +OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}" +OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}" +OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" +OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" +OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}" +OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}" +OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}" +OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}" + +PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}" +CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}" +RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}" +STRICT_CASES="${STRICT_CASES:-1}" +AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}" +OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}" +OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}" +OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}" +PAIRING_AUTO_APPROVE="${PAIRING_AUTO_APPROVE:-1}" +PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}" + +AUTH_HEADERS=() +if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then + AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" ) +fi +if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then + AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" ) + PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}" + AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" ) +fi + +RESPONSE_CODE="" +RESPONSE_BODY="" +COMPANY_ID="" +AGENT_ID="" +AGENT_API_KEY="" +JOIN_REQUEST_ID="" +INVITE_ID="" +RUN_ID="" + +CASE_A_ISSUE_ID="" +CASE_B_ISSUE_ID="" +CASE_C_ISSUE_ID="" +CASE_C_CREATED_ISSUE_ID="" + +api_request() { + local method="$1" + local path="$2" + local data="${3-}" + local tmp + tmp="$(mktemp)" + + local url + if [[ "$path" == http://* || "$path" == https://* ]]; then + url="$path" + elif [[ "$path" == /api/* ]]; then + url="${PAPERCLIP_API_URL%/}${path}" + else + url="${API_BASE}${path}" + fi + + if [[ -n "$data" ]]; then + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")" + fi + else + if (( ${#AUTH_HEADERS[@]} > 0 )); then + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")" + else + RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")" + fi + fi + + RESPONSE_BODY="$(cat "$tmp")" + rm -f "$tmp" +} + +capture_run_diagnostics() { + local run_id="$1" + local label="${2:-run}" + [[ -n "$run_id" ]] || return 0 + + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json" + else + warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi + + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json" + jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true + else + warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})" + fi +} + +capture_issue_diagnostics() { + local issue_id="$1" + local label="${2:-issue}" + [[ -n "$issue_id" ]] || return 0 + mkdir -p "$OPENCLAW_DIAG_DIR" + + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json" + fi + + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" == "200" ]]; then + printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json" + fi +} + +capture_openclaw_container_logs() { + mkdir -p "$OPENCLAW_DIAG_DIR" + local container + container="$(detect_openclaw_container || true)" + if [[ -z "$container" ]]; then + warn "could not detect OpenClaw container for diagnostics" + return 0 + fi + docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true +} + +assert_status() { + local expected="$1" + if [[ "$RESPONSE_CODE" != "$expected" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "expected HTTP ${expected}, got ${RESPONSE_CODE}" + fi +} + +require_board_auth() { + if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then + fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER." + fi + api_request "GET" "/companies" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})" + fi +} + +maybe_cleanup_openclaw_docker() { + if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then + log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup" + return + fi + + log "cleaning OpenClaw docker state" + if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then + docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true + fi + if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true + fi + docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true +} + +start_openclaw_docker() { + log "starting clean OpenClaw docker" + OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \ + OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \ + ./scripts/smoke/openclaw-docker-ui.sh +} + +wait_http_ready() { + local url="$1" + local timeout_sec="$2" + local started_at now code + started_at="$(date +%s)" + while true; do + code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)" + if [[ "$code" == "200" ]]; then + return 0 + fi + now="$(date +%s)" + if (( now - started_at >= timeout_sec )); then + return 1 + fi + sleep 1 + done +} + +detect_openclaw_container() { + if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then + echo "$OPENCLAW_CONTAINER_NAME" + return 0 + fi + + local detected + detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)" + if [[ -n "$detected" ]]; then + echo "$detected" + return 0 + fi + + return 1 +} + +detect_gateway_token() { + if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then + echo "$OPENCLAW_GATEWAY_TOKEN" + return 0 + fi + + local config_path + config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json" + if [[ -f "$config_path" ]]; then + local token + token="$(jq -r '.gateway.auth.token // empty' "$config_path")" + if [[ -n "$token" ]]; then + echo "$token" + return 0 + fi + fi + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + local token_from_container + token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)" + if [[ -n "$token_from_container" ]]; then + echo "$token_from_container" + return 0 + fi + fi + + return 1 +} + +hash_prefix() { + local value="$1" + printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12 +} + +probe_gateway_ws() { + local url="$1" + local token="$2" + + node - "$url" "$token" <<'NODE' +const WebSocket = require("ws"); +const url = process.argv[2]; +const token = process.argv[3]; + +const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }); +const timeout = setTimeout(() => { + console.error("gateway probe timed out"); + process.exit(2); +}, 8000); + +ws.on("message", (raw) => { + try { + const message = JSON.parse(String(raw)); + if (message?.type === "event" && message?.event === "connect.challenge") { + clearTimeout(timeout); + ws.close(); + process.exit(0); + } + } catch { + // ignore + } +}); + +ws.on("error", (err) => { + clearTimeout(timeout); + console.error(err?.message || String(err)); + process.exit(1); +}); +NODE +} + +resolve_company_id() { + api_request "GET" "/companies" + assert_status "200" + + local selector + selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')" + + COMPANY_ID="$(jq -r --arg sel "$selector" ' + map(select( + ((.id // "") | ascii_upcase) == $sel or + ((.name // "") | ascii_upcase) == $sel or + ((.issuePrefix // "") | ascii_upcase) == $sel + )) + | .[0].id // empty + ' <<<"$RESPONSE_BODY")" + + if [[ -z "$COMPANY_ID" ]]; then + local available + available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")" + echo "$available" >&2 + fail "could not find company for selector '${COMPANY_SELECTOR}'" + fi + + log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}" +} + +cleanup_openclaw_agents() { + api_request "GET" "/companies/${COMPANY_ID}/agents" + assert_status "200" + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + log "no prior OpenClaw agents to cleanup" + return + fi + + while IFS= read -r id; do + [[ -n "$id" ]] || continue + log "terminating prior OpenClaw agent ${id}" + api_request "POST" "/agents/${id}/terminate" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "terminate ${id} returned HTTP ${RESPONSE_CODE}" + fi + + api_request "DELETE" "/agents/${id}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then + warn "delete ${id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +cleanup_pending_join_requests() { + api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval" + if [[ "$RESPONSE_CODE" != "200" ]]; then + warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})" + return + fi + + local ids + ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")" + if [[ -z "$ids" ]]; then + return + fi + + while IFS= read -r request_id; do + [[ -n "$request_id" ]] || continue + log "rejecting stale pending join request ${request_id}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}" + if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then + warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}" + fi + done <<<"$ids" +} + +create_and_approve_gateway_join() { + local gateway_token="$1" + + local invite_payload + invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')" + api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload" + assert_status "201" + + local invite_token + invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + [[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id" + + local join_payload + join_payload="$(jq -nc \ + --arg name "$OPENCLAW_AGENT_NAME" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$gateway_token" \ + --arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \ + --argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \ + --argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \ + '{ + requestType: "agent", + agentName: $name, + adapterType: "openclaw_gateway", + capabilities: "OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + role: "operator", + scopes: ["operator.admin"], + sessionKeyStrategy: "fixed", + sessionKey: "paperclip", + timeoutSec: $timeoutSec, + waitTimeoutMs: $waitTimeoutMs, + paperclipApiUrl: $paperclipApiUrl + } + }')" + + api_request "POST" "/invites/${invite_token}/accept" "$join_payload" + assert_status "202" + + JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + local claim_secret + claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")" + local claim_path + claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")" + [[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata" + + log "approving join request ${JOIN_REQUEST_ID}" + api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}" + assert_status "200" + + AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId" + + log "claiming one-time agent API key" + local claim_payload + claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')" + api_request "POST" "$claim_path" "$claim_payload" + assert_status "201" + + AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")" + [[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token" + + persist_claimed_key_artifacts "$RESPONSE_BODY" + inject_agent_api_key_payload_template +} + +persist_claimed_key_artifacts() { + local claim_json="$1" + local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace" + local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip" + local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json" + local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json" + + mkdir -p "$workspace_dir" "$skill_dir" + local token + token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")" + [[ -n "$token" ]] || fail "claim response missing token/apiKey" + + printf "%s\n" "$claim_json" > "$claimed_raw_file" + chmod 600 "$claimed_raw_file" + + jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file" + # Keep this readable for OpenClaw runtime users across sandbox/container contexts. + chmod 644 "$claimed_file" + + local container + container="$(detect_openclaw_container || true)" + if [[ -n "$container" ]]; then + docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true + docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true + fi + + if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then + api_request "GET" "/skills/paperclip" + assert_status "200" + { + echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}" + echo + printf "%s\n" "$RESPONSE_BODY" + } > "${skill_dir}/SKILL.md" + chmod 600 "${skill_dir}/SKILL.md" + fi + + log "wrote claimed key artifacts to ${claimed_file} and ${claimed_raw_file}" +} + +inject_agent_api_key_payload_template() { + [[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID" + [[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY" + + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local base_message + base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps." + if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then + base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}" + fi + + local patch_payload + patch_payload="$(jq -c --arg message "$base_message" ' + {adapterConfig: ((.adapterConfig // {}) + { + payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + { + message: $message + }) + })} + ' <<<"$RESPONSE_BODY")" + + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +validate_joined_gateway_agent() { + local expected_gateway_token="$1" + + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local adapter_type gateway_url configured_token disable_device_auth device_key_len + adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")" + gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")" + configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")" + disable_device_auth="$(jq -r 'if .adapterConfig.disableDeviceAuth == true then "true" else "false" end' <<<"$RESPONSE_BODY")" + device_key_len="$(jq -r '(.adapterConfig.devicePrivateKeyPem // "" | length)' <<<"$RESPONSE_BODY")" + + [[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'" + [[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'" + [[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token" + if (( ${#configured_token} < 16 )); then + fail "joined agent gateway token looks too short (${#configured_token} chars)" + fi + + local expected_hash configured_hash + expected_hash="$(hash_prefix "$expected_gateway_token")" + configured_hash="$(hash_prefix "$configured_token")" + if [[ "$expected_hash" != "$configured_hash" ]]; then + fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})" + fi + + [[ "$disable_device_auth" == "false" ]] || fail "joined agent has disableDeviceAuth=true; smoke requires device auth enabled with persistent key" + if (( device_key_len < 32 )); then + fail "joined agent missing persistent devicePrivateKeyPem (length=${device_key_len})" + fi + + log "validated joined gateway agent config (token sha256 prefix ${configured_hash})" +} + +run_log_contains_pairing_required() { + local run_id="$1" + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=262144" + if [[ "$RESPONSE_CODE" != "200" ]]; then + return 1 + fi + local content + content="$(jq -r '.content // ""' <<<"$RESPONSE_BODY")" + grep -qi "pairing required" <<<"$content" +} + +approve_latest_pairing_request() { + local gateway_token="$1" + local container + container="$(detect_openclaw_container || true)" + [[ -n "$container" ]] || return 1 + + log "approving latest gateway pairing request in ${container}" + local output + if output="$(docker exec \ + -e OPENCLAW_GATEWAY_URL="$OPENCLAW_GATEWAY_URL" \ + -e OPENCLAW_GATEWAY_TOKEN="$gateway_token" \ + "$container" \ + sh -lc 'openclaw devices approve --latest --json --url "$OPENCLAW_GATEWAY_URL" --token "$OPENCLAW_GATEWAY_TOKEN"' 2>&1)"; then + log "pairing approval response: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)" + return 0 + fi + + warn "pairing auto-approve failed: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)" + return 1 +} + +trigger_wakeup() { + local reason="$1" + local issue_id="${2:-}" + + local payload + if [[ -n "$issue_id" ]]; then + payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')" + else + payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')" + fi + + api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload" + if [[ "$RESPONSE_CODE" != "202" ]]; then + echo "$RESPONSE_BODY" >&2 + fail "wakeup failed (HTTP ${RESPONSE_CODE})" + fi + + RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + if [[ -z "$RUN_ID" ]]; then + warn "wakeup response did not include run id; body: ${RESPONSE_BODY}" + fi +} + +get_run_status() { + local run_id="$1" + api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1 +} + +wait_for_run_terminal() { + local run_id="$1" + local timeout_sec="$2" + local started now status + + [[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id" + started="$(date +%s)" + + while true; do + status="$(get_run_status "$run_id")" + if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then + if [[ "$status" != "succeeded" ]]; then + capture_run_diagnostics "$run_id" "run-nonsuccess" + capture_openclaw_container_logs + fi + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + capture_run_diagnostics "$run_id" "run-timeout" + capture_openclaw_container_logs + echo "timeout" + return 0 + fi + sleep 2 + done +} + +get_issue_status() { + local issue_id="$1" + api_request "GET" "/issues/${issue_id}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.status // empty' <<<"$RESPONSE_BODY" +} + +wait_for_issue_terminal() { + local issue_id="$1" + local timeout_sec="$2" + local started now status + started="$(date +%s)" + + while true; do + status="$(get_issue_status "$issue_id")" + if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then + echo "$status" + return 0 + fi + + now="$(date +%s)" + if (( now - started >= timeout_sec )); then + echo "timeout" + return 0 + fi + sleep 3 + done +} + +issue_comments_contain() { + local issue_id="$1" + local marker="$2" + api_request "GET" "/issues/${issue_id}/comments" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "false" + return 0 + fi + jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY" +} + +create_issue_for_case() { + local title="$1" + local description="$2" + local priority="${3:-high}" + + local payload + payload="$(jq -nc \ + --arg title "$title" \ + --arg description "$description" \ + --arg assignee "$AGENT_ID" \ + --arg priority "$priority" \ + '{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')" + + api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload" + assert_status "201" + + local issue_id issue_identifier + issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")" + issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")" + [[ -n "$issue_id" ]] || fail "issue create missing id" + + echo "${issue_id}|${issue_identifier}" +} + +patch_agent_session_strategy_run() { + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local patch_payload + patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")" + api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload" + assert_status "200" +} + +find_issue_by_query() { + local query="$1" + local encoded_query + encoded_query="$(jq -rn --arg q "$query" '$q|@uri')" + api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}" + if [[ "$RESPONSE_CODE" != "200" ]]; then + echo "" + return 0 + fi + jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1 +} + +run_case_a() { + local marker="OPENCLAW_CASE_A_OK_$(date +%s)" + local description + description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")" + CASE_A_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case A run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")" + log "case A issue_status=${issue_status} marker_found=${marker_found}" + + if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-a" + fi + capture_openclaw_container_logs + fi + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case A issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case A marker not found in comments" + fi +} + +run_case_b() { + local marker="OPENCLAW_CASE_B_OK_$(date +%s)" + local message_text="${marker}" + local description + description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")" + CASE_B_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID" + + local run_status issue_status marker_found + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case B run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")" + log "case B issue_status=${issue_status} marker_found=${marker_found}" + + if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then + capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b" + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-b" + fi + capture_openclaw_container_logs + fi + + warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat" + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case B issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case B marker not found in comments" + fi +} + +run_case_c() { + patch_agent_session_strategy_run + + local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)" + local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)" + local original_issue_reference="the original case issue you are currently reading" + local description + description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on ${original_issue_reference} containing exactly: ${ack_marker}\nDo NOT post the ACK comment on the newly created issue.\nThen mark the original case issue done." + + local created + created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")" + CASE_C_ISSUE_ID="${created%%|*}" + local case_identifier="${created##*|}" + + log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})" + trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID" + + local run_status issue_status marker_found created_issue + if [[ -n "$RUN_ID" ]]; then + run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + log "case C run ${RUN_ID} status=${run_status}" + else + run_status="unknown" + fi + + issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")" + marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")" + created_issue="$(find_issue_by_query "$marker")" + if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then + created_issue="" + fi + CASE_C_CREATED_ISSUE_ID="$created_issue" + log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}" + + if [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c" + if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then + capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created" + fi + if [[ -n "$RUN_ID" ]]; then + capture_run_diagnostics "$RUN_ID" "case-c" + fi + capture_openclaw_container_logs + fi + + if [[ "$STRICT_CASES" == "1" ]]; then + [[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed" + [[ "$issue_status" == "done" ]] || fail "case C issue did not reach done" + [[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments" + [[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue" + fi +} + +main() { + log "starting OpenClaw gateway E2E smoke" + mkdir -p "$OPENCLAW_DIAG_DIR" + log "diagnostics dir: ${OPENCLAW_DIAG_DIR}" + + wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable" + api_request "GET" "/health" + assert_status "200" + log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")" + + require_board_auth + resolve_company_id + cleanup_openclaw_agents + cleanup_pending_join_requests + + maybe_cleanup_openclaw_docker + start_openclaw_docker + wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable" + + local gateway_token + gateway_token="$(detect_gateway_token || true)" + [[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token" + log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))" + + log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}" + probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token" + + create_and_approve_gateway_join "$gateway_token" + log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}" + validate_joined_gateway_agent "$gateway_token" + + local connect_status="unknown" + local connect_attempt + for connect_attempt in 1 2; do + trigger_wakeup "openclaw_gateway_smoke_connectivity_attempt_${connect_attempt}" + if [[ -z "$RUN_ID" ]]; then + connect_status="unknown" + break + fi + connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")" + if [[ "$connect_status" == "succeeded" ]]; then + log "connectivity wake run ${RUN_ID} succeeded (attempt=${connect_attempt})" + break + fi + + if [[ "$PAIRING_AUTO_APPROVE" == "1" && "$connect_attempt" -eq 1 ]] && run_log_contains_pairing_required "$RUN_ID"; then + log "connectivity run hit pairing gate; attempting one-time pairing approval" + approve_latest_pairing_request "$gateway_token" || fail "pairing approval failed after pairing-required run ${RUN_ID}" + sleep 2 + continue + fi + + fail "connectivity wake run failed: ${connect_status} (attempt=${connect_attempt}, runId=${RUN_ID})" + done + [[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run did not succeed after retries" + + run_case_a + run_case_b + run_case_c + + log "success" + log "companyId=${COMPANY_ID}" + log "agentId=${AGENT_ID}" + log "inviteId=${INVITE_ID}" + log "joinRequestId=${JOIN_REQUEST_ID}" + log "caseA_issueId=${CASE_A_ISSUE_ID}" + log "caseB_issueId=${CASE_B_ISSUE_ID}" + log "caseC_issueId=${CASE_C_ISSUE_ID}" + log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}" + log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..." +} + +main "$@" diff --git a/scripts/smoke/openclaw-join.sh b/scripts/smoke/openclaw-join.sh index d5da01c2..23896e8a 100755 --- a/scripts/smoke/openclaw-join.sh +++ b/scripts/smoke/openclaw-join.sh @@ -160,7 +160,7 @@ if [[ -z "$COMPANY_ID" ]]; then fi log "creating agent-only invite for company ${COMPANY_ID}" -INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent",expiresInHours:24}')" +INVITE_PAYLOAD="$(jq -nc '{allowedJoinTypes:"agent"}')" api_request "POST" "/companies/${COMPANY_ID}/invites" "$INVITE_PAYLOAD" if [[ "$RESPONSE_CODE" == "401" || "$RESPONSE_CODE" == "403" ]]; then fail_board_auth_required "Invite creation" @@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then fi api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" assert_status "200" -if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then +if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then fail "onboarding.txt response missing expected header" fi -log "submitting OpenClaw agent join request" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}" +if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then + fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join" +fi + +log "submitting OpenClaw gateway agent join request" JOIN_PAYLOAD="$(jq -nc \ --arg name "$OPENCLAW_AGENT_NAME" \ - --arg url "$OPENCLAW_WEBHOOK_URL" \ - --arg auth "$OPENCLAW_WEBHOOK_AUTH" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$OPENCLAW_GATEWAY_TOKEN" \ '{ requestType: "agent", agentName: $name, - adapterType: "openclaw", - capabilities: "Automated OpenClaw smoke harness", - agentDefaultsPayload: ( - { url: $url, method: "POST", timeoutSec: 30 } - + (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end) - ) + adapterType: "openclaw_gateway", + capabilities: "Automated OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + sessionKeyStrategy: "issue", + waitTimeoutMs: 120000 + } }')" api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" assert_status "202" diff --git a/scripts/smoke/openclaw-sse-standalone.sh b/scripts/smoke/openclaw-sse-standalone.sh new file mode 100755 index 00000000..65b42ae7 --- /dev/null +++ b/scripts/smoke/openclaw-sse-standalone.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + echo "[openclaw-sse-standalone] $*" +} + +fail() { + echo "[openclaw-sse-standalone] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd" +} + +require_cmd curl +require_cmd jq +require_cmd grep + +OPENCLAW_URL="${OPENCLAW_URL:-}" +OPENCLAW_METHOD="${OPENCLAW_METHOD:-POST}" +OPENCLAW_AUTH_HEADER="${OPENCLAW_AUTH_HEADER:-}" +OPENCLAW_TIMEOUT_SEC="${OPENCLAW_TIMEOUT_SEC:-180}" +OPENCLAW_MODEL="${OPENCLAW_MODEL:-openclaw}" +OPENCLAW_USER="${OPENCLAW_USER:-paperclip-smoke}" + +PAPERCLIP_RUN_ID="${PAPERCLIP_RUN_ID:-smoke-run-$(date +%s)}" +PAPERCLIP_AGENT_ID="${PAPERCLIP_AGENT_ID:-openclaw-smoke-agent}" +PAPERCLIP_COMPANY_ID="${PAPERCLIP_COMPANY_ID:-openclaw-smoke-company}" +PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://localhost:3100}" +PAPERCLIP_TASK_ID="${PAPERCLIP_TASK_ID:-openclaw-smoke-task}" +PAPERCLIP_WAKE_REASON="${PAPERCLIP_WAKE_REASON:-openclaw_smoke_test}" +PAPERCLIP_WAKE_COMMENT_ID="${PAPERCLIP_WAKE_COMMENT_ID:-}" +PAPERCLIP_APPROVAL_ID="${PAPERCLIP_APPROVAL_ID:-}" +PAPERCLIP_APPROVAL_STATUS="${PAPERCLIP_APPROVAL_STATUS:-}" +PAPERCLIP_LINKED_ISSUE_IDS="${PAPERCLIP_LINKED_ISSUE_IDS:-}" +OPENCLAW_TEXT_PREFIX="${OPENCLAW_TEXT_PREFIX:-Standalone OpenClaw SSE smoke test.}" + +[[ -n "$OPENCLAW_URL" ]] || fail "OPENCLAW_URL is required" + +read -r -d '' TEXT_BODY <&2 || true + fail "non-success HTTP status: ${http_code}" +fi + +if ! grep -Eqi '^content-type:.*text/event-stream' "$headers_file"; then + tail -n 40 "$body_file" >&2 || true + fail "response content-type was not text/event-stream" +fi + +if grep -Eqi 'event:\s*(error|failed|cancel)|"status":"(failed|cancelled|error)"|"type":"[^"]*(failed|cancelled|error)"' "$body_file"; then + tail -n 120 "$body_file" >&2 || true + fail "stream reported a failure event" +fi + +if ! grep -Eqi 'event:\s*(done|completed|response\.completed)|\[DONE\]|"status":"(completed|succeeded|done)"|"type":"response\.completed"' "$body_file"; then + tail -n 120 "$body_file" >&2 || true + fail "stream ended without a terminal completion marker" +fi + +event_count="$(grep -Ec '^event:' "$body_file" || true)" +log "stream completed successfully (events=${event_count})" +echo +tail -n 40 "$body_file" diff --git a/server/package.json b/server/package.json index 781f452a..3e74286b 100644 --- a/server/package.json +++ b/server/package.json @@ -35,7 +35,8 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-pi-local": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", @@ -57,7 +58,9 @@ "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", "@types/multer": "^2.0.0", + "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", + "@types/ws": "^8.18.1", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 81be014e..a6c5eb35 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local"; +import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js"; @@ -9,9 +10,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ". describe("adapter model listing", () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; + delete process.env.PAPERCLIP_OPENCODE_COMMAND; resetCodexModelsCacheForTests(); resetCursorModelsCacheForTests(); setCursorModelsRunnerForTests(null); + resetOpenCodeModelsCacheForTests(); vi.restoreAllMocks(); }); @@ -61,6 +64,7 @@ describe("adapter model listing", () => { expect(models).toEqual(codexFallbackModels); }); + it("returns cursor fallback models when CLI discovery is unavailable", async () => { setCursorModelsRunnerForTests(() => ({ status: null, @@ -74,6 +78,8 @@ describe("adapter model listing", () => { }); it("returns opencode fallback models including gpt-5.4", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + const models = await listAdapterModels("opencode_local"); expect(models).toEqual(opencodeFallbackModels); @@ -97,4 +103,5 @@ describe("adapter model listing", () => { expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true); expect(first.some((model) => model.id === "composer-1")).toBe(true); }); + }); diff --git a/server/src/__tests__/agent-shortname-collision.test.ts b/server/src/__tests__/agent-shortname-collision.test.ts new file mode 100644 index 00000000..396152cb --- /dev/null +++ b/server/src/__tests__/agent-shortname-collision.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { hasAgentShortnameCollision, deduplicateAgentName } from "../services/agents.ts"; + +describe("hasAgentShortnameCollision", () => { + it("detects collisions by normalized shortname", () => { + const collision = hasAgentShortnameCollision("Codex Coder", [ + { id: "a1", name: "codex-coder", status: "idle" }, + ]); + expect(collision).toBe(true); + }); + + it("ignores terminated agents", () => { + const collision = hasAgentShortnameCollision("Codex Coder", [ + { id: "a1", name: "codex-coder", status: "terminated" }, + ]); + expect(collision).toBe(false); + }); + + it("ignores the excluded agent id", () => { + const collision = hasAgentShortnameCollision( + "Codex Coder", + [ + { id: "a1", name: "codex-coder", status: "idle" }, + { id: "a2", name: "other-agent", status: "idle" }, + ], + { excludeAgentId: "a1" }, + ); + expect(collision).toBe(false); + }); + + it("does not collide when candidate has no shortname", () => { + const collision = hasAgentShortnameCollision("!!!", [ + { id: "a1", name: "codex-coder", status: "idle" }, + ]); + expect(collision).toBe(false); + }); +}); + +describe("deduplicateAgentName", () => { + it("returns original name when no collision", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "other-agent", status: "idle" }, + ]); + expect(name).toBe("OpenClaw"); + }); + + it("appends suffix when name collides", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "idle" }, + ]); + expect(name).toBe("OpenClaw 2"); + }); + + it("increments suffix until unique", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "idle" }, + { id: "a2", name: "openclaw-2", status: "idle" }, + { id: "a3", name: "openclaw-3", status: "idle" }, + ]); + expect(name).toBe("OpenClaw 4"); + }); + + it("ignores terminated agents for collision", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "terminated" }, + ]); + expect(name).toBe("OpenClaw"); + }); +}); diff --git a/server/src/__tests__/companies-route-path-guard.test.ts b/server/src/__tests__/companies-route-path-guard.test.ts new file mode 100644 index 00000000..ede6d024 --- /dev/null +++ b/server/src/__tests__/companies-route-path-guard.test.ts @@ -0,0 +1,49 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { companyRoutes } from "../routes/companies.js"; + +vi.mock("../services/index.js", () => ({ + companyService: () => ({ + list: vi.fn(), + stats: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + archive: vi.fn(), + remove: vi.fn(), + }), + companyPortabilityService: () => ({ + exportBundle: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), + }), + accessService: () => ({ + canUser: vi.fn(), + ensureMembership: vi.fn(), + }), + logActivity: vi.fn(), +})); + +describe("company routes malformed issue path guard", () => { + it("returns a clear error when companyId is missing for issues list path", async () => { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }; + next(); + }); + app.use("/api/companies", companyRoutes({} as any)); + + const res = await request(app).get("/api/companies/issues"); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", + }); + }); +}); diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts new file mode 100644 index 00000000..d01a8c3c --- /dev/null +++ b/server/src/__tests__/error-handler.test.ts @@ -0,0 +1,53 @@ +import type { NextFunction, Request, Response } from "express"; +import { describe, expect, it, vi } from "vitest"; +import { HttpError } from "../errors.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +function makeReq(): Request { + return { + method: "GET", + originalUrl: "/api/test", + body: { a: 1 }, + params: { id: "123" }, + query: { q: "x" }, + } as unknown as Request; +} + +function makeRes(): Response { + const res = { + status: vi.fn(), + json: vi.fn(), + } as unknown as Response; + (res.status as unknown as ReturnType).mockReturnValue(res); + return res; +} + +describe("errorHandler", () => { + it("attaches the original Error to res.err for 500s", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new Error("boom"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("boom"); + }); + + it("attaches HttpError instances for 500 responses", () => { + const req = makeReq(); + const res = makeRes() as any; + const next = vi.fn() as unknown as NextFunction; + const err = new HttpError(500, "db exploded"); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "db exploded" }); + expect(res.err).toBe(err); + expect(res.__errorContext?.error?.message).toBe("db exploded"); + }); +}); diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts new file mode 100644 index 00000000..0a2cbbfd --- /dev/null +++ b/server/src/__tests__/hire-hook.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { notifyHireApproved } from "../services/hire-hook.js"; + +// Mock the registry so we control whether the adapter has onHireApproved and what it does. +vi.mock("../adapters/registry.js", () => ({ + findServerAdapter: vi.fn(), +})); + +vi.mock("../services/activity-log.js", () => ({ + logActivity: vi.fn().mockResolvedValue(undefined), +})); + +const { findServerAdapter } = await import("../adapters/registry.js"); +const { logActivity } = await import("../services/activity-log.js"); + +function mockDbWithAgent(agent: { id: string; companyId: string; name: string; adapterType: string; adapterConfig?: Record }): Db { + return { + select: () => ({ + from: () => ({ + where: () => + Promise.resolve([ + { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + adapterType: agent.adapterType, + adapterConfig: agent.adapterConfig ?? {}, + }, + ]), + }), + }), + } as unknown as Db; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("notifyHireApproved", () => { + it("writes success activity when adapter hook returns ok", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw_gateway", + onHireApproved: vi.fn().mockResolvedValue({ ok: true }), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw_gateway", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "approval", + sourceId: "ap1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.succeeded", + entityId: "a1", + details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }), + }), + ); + }); + + it("does nothing when agent is not found", async () => { + const db = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve([]), + }), + }), + } as unknown as Db; + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(findServerAdapter).not.toHaveBeenCalled(); + }); + + it("does nothing when adapter has no onHireApproved", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ type: "process" } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "Agent", + adapterType: "process", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "approval", + sourceId: "ap1", + }), + ).resolves.toBeUndefined(); + + expect(findServerAdapter).toHaveBeenCalledWith("process"); + expect(logActivity).not.toHaveBeenCalled(); + }); + + it("logs failed result when adapter onHireApproved returns ok=false", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw_gateway", + onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw_gateway", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.failed", + entityId: "a1", + details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "HTTP 500" }), + }), + ); + }); + + it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { + vi.mocked(findServerAdapter).mockReturnValue({ + type: "openclaw_gateway", + onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), + } as any); + + const db = mockDbWithAgent({ + id: "a1", + companyId: "c1", + name: "OpenClaw Agent", + adapterType: "openclaw_gateway", + }); + + await expect( + notifyHireApproved(db, { + companyId: "c1", + agentId: "a1", + source: "join_request", + sourceId: "jr1", + }), + ).resolves.toBeUndefined(); + + expect(logActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "hire_hook.error", + entityId: "a1", + details: expect.objectContaining({ source: "join_request", sourceId: "jr1", error: "Network error" }), + }), + ); + }); +}); diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts new file mode 100644 index 00000000..3ff239f6 --- /dev/null +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + normalizeAgentDefaultsForJoin, +} from "../routes/access.js"; + +describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { + it("leaves non-gateway payloads unchanged", () => { + const defaultsPayload = { command: "echo hello" }; + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "process", + defaultsPayload, + inboundOpenClawAuthHeader: "ignored-token", + }); + + expect(result).toEqual(defaultsPayload); + }); + + it("normalizes wrapped x-openclaw-token header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": { + value: "gateway-token-1234567890", + }, + }, + }, + }) as Record; + + expect(result).toMatchObject({ + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("accepts inbound x-openclaw-token for gateway joins", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + }, + inboundOpenClawTokenHeader: "gateway-token-1234567890", + }) as Record; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("derives x-openclaw-token from authorization header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + authorization: "Bearer gateway-token-1234567890", + }, + }, + }) as Record; + + expect(result).toMatchObject({ + headers: { + authorization: "Bearer gateway-token-1234567890", + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); +}); + +describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { + it("generates persistent device key when device auth is enabled", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: false, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(false); + expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string"); + expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64); + }); + + it("does not generate device key when disableDeviceAuth=true", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: true, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(true); + expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/invite-accept-replay.test.ts b/server/src/__tests__/invite-accept-replay.test.ts new file mode 100644 index 00000000..dba43dbd --- /dev/null +++ b/server/src/__tests__/invite-accept-replay.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + canReplayOpenClawGatewayInviteAccept, + mergeJoinDefaultsPayloadForReplay, +} from "../routes/access.js"; + +describe("canReplayOpenClawGatewayInviteAccept", () => { + it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => { + expect( + canReplayOpenClawGatewayInviteAccept({ + requestType: "agent", + adapterType: "openclaw_gateway", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw_gateway", + status: "pending_approval", + }, + }), + ).toBe(true); + + expect( + canReplayOpenClawGatewayInviteAccept({ + requestType: "agent", + adapterType: "openclaw_gateway", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw_gateway", + status: "approved", + }, + }), + ).toBe(true); + + expect( + canReplayOpenClawGatewayInviteAccept({ + requestType: "agent", + adapterType: "openclaw_gateway", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw_gateway", + status: "rejected", + }, + }), + ).toBe(false); + + expect( + canReplayOpenClawGatewayInviteAccept({ + requestType: "human", + adapterType: "openclaw_gateway", + existingJoinRequest: { + requestType: "agent", + adapterType: "openclaw_gateway", + status: "pending_approval", + }, + }), + ).toBe(false); + }); +}); + +describe("mergeJoinDefaultsPayloadForReplay", () => { + it("merges replay payloads and allows gateway token override", () => { + const merged = mergeJoinDefaultsPayloadForReplay( + { + url: "ws://old.example:18789", + paperclipApiUrl: "http://host.docker.internal:3100", + headers: { + "x-openclaw-token": "old-token-1234567890", + "x-custom": "keep-me", + }, + }, + { + paperclipApiUrl: "https://paperclip.example.com", + headers: { + "x-openclaw-token": "new-token-1234567890", + }, + }, + ); + + const normalized = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: merged, + inboundOpenClawAuthHeader: null, + }) as Record; + + expect(normalized.url).toBe("ws://old.example:18789"); + expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com"); + expect(normalized.headers).toMatchObject({ + "x-openclaw-token": "new-token-1234567890", + "x-custom": "keep-me", + }); + }); +}); diff --git a/server/src/__tests__/invite-expiry.test.ts b/server/src/__tests__/invite-expiry.test.ts new file mode 100644 index 00000000..c84a2a95 --- /dev/null +++ b/server/src/__tests__/invite-expiry.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { companyInviteExpiresAt } from "../routes/access.js"; + +describe("companyInviteExpiresAt", () => { + it("sets invite expiration to 10 minutes after invite creation time", () => { + const createdAtMs = Date.parse("2026-03-06T00:00:00.000Z"); + const expiresAt = companyInviteExpiresAt(createdAtMs); + expect(expiresAt.toISOString()).toBe("2026-03-06T00:10:00.000Z"); + }); +}); diff --git a/server/src/__tests__/invite-join-manager.test.ts b/server/src/__tests__/invite-join-manager.test.ts new file mode 100644 index 00000000..92770a25 --- /dev/null +++ b/server/src/__tests__/invite-join-manager.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { resolveJoinRequestAgentManagerId } from "../routes/access.js"; + +describe("resolveJoinRequestAgentManagerId", () => { + it("returns null when no CEO exists in the company agent list", () => { + const managerId = resolveJoinRequestAgentManagerId([ + { id: "a1", role: "cto", reportsTo: null }, + { id: "a2", role: "engineer", reportsTo: "a1" }, + ]); + + expect(managerId).toBeNull(); + }); + + it("selects the root CEO when available", () => { + const managerId = resolveJoinRequestAgentManagerId([ + { id: "ceo-child", role: "ceo", reportsTo: "manager-1" }, + { id: "manager-1", role: "cto", reportsTo: null }, + { id: "ceo-root", role: "ceo", reportsTo: null }, + ]); + + expect(managerId).toBe("ceo-root"); + }); + + it("falls back to the first CEO when no root CEO is present", () => { + const managerId = resolveJoinRequestAgentManagerId([ + { id: "ceo-1", role: "ceo", reportsTo: "mgr" }, + { id: "ceo-2", role: "ceo", reportsTo: "mgr" }, + { id: "mgr", role: "cto", reportsTo: null }, + ]); + + expect(managerId).toBe("ceo-1"); + }); +}); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 10ed81e7..8ba30115 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -37,10 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => { allowedHostnames: [], }); - expect(text).toContain("Paperclip OpenClaw Onboarding"); + expect(text).toContain("Paperclip OpenClaw Gateway Onboarding"); expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); + expect(text).toContain("Suggested Paperclip base URLs to try"); + expect(text).toContain("http://localhost:3100"); + expect(text).toContain("host.docker.internal"); + expect(text).toContain("paperclipApiUrl"); + expect(text).toContain("adapterType \"openclaw_gateway\""); + expect(text).toContain("headers.x-openclaw-token"); + expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); + expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); + expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json"); + expect(text).toContain("PAPERCLIP_API_KEY"); + expect(text).toContain("saved token field"); + expect(text).toContain("Gateway token unexpectedly short"); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { @@ -69,5 +81,36 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("Connectivity diagnostics"); expect(text).toContain("loopback hostname"); + expect(text).toContain("If none are reachable"); + }); + + it("includes inviter message in the onboarding text when provided", () => { + const req = buildReq("localhost:3100"); + const invite = { + id: "invite-3", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: { + agentMessage: "Please join as our QA lead and prioritize flaky test triage first.", + }, + expiresAt: new Date("2026-03-05T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-04T00:00:00.000Z"), + updatedAt: new Date("2026-03-04T00:00:00.000Z"), + } as const; + + const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(text).toContain("Message from inviter"); + expect(text).toContain("prioritize flaky test triage first"); }); }); diff --git a/server/src/__tests__/issues-checkout-wakeup.test.ts b/server/src/__tests__/issues-checkout-wakeup.test.ts new file mode 100644 index 00000000..12b23870 --- /dev/null +++ b/server/src/__tests__/issues-checkout-wakeup.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { shouldWakeAssigneeOnCheckout } from "../routes/issues-checkout-wakeup.js"; + +describe("shouldWakeAssigneeOnCheckout", () => { + it("keeps wakeup behavior for board actors", () => { + expect( + shouldWakeAssigneeOnCheckout({ + actorType: "board", + actorAgentId: null, + checkoutAgentId: "agent-1", + checkoutRunId: null, + }), + ).toBe(true); + }); + + it("skips wakeup for agent self-checkout in an active run", () => { + expect( + shouldWakeAssigneeOnCheckout({ + actorType: "agent", + actorAgentId: "agent-1", + checkoutAgentId: "agent-1", + checkoutRunId: "run-1", + }), + ).toBe(false); + }); + + it("still wakes when checkout run id is missing", () => { + expect( + shouldWakeAssigneeOnCheckout({ + actorType: "agent", + actorAgentId: "agent-1", + checkoutAgentId: "agent-1", + checkoutRunId: null, + }), + ).toBe(true); + }); + + it("still wakes when agent checks out on behalf of another agent id", () => { + expect( + shouldWakeAssigneeOnCheckout({ + actorType: "agent", + actorAgentId: "agent-1", + checkoutAgentId: "agent-2", + checkoutRunId: "run-1", + }), + ).toBe(true); + }); +}); diff --git a/server/src/__tests__/issues-user-context.test.ts b/server/src/__tests__/issues-user-context.test.ts new file mode 100644 index 00000000..80c7d37b --- /dev/null +++ b/server/src/__tests__/issues-user-context.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { deriveIssueUserContext } from "../services/issues.ts"; + +function makeIssue(overrides?: Partial<{ + createdByUserId: string | null; + assigneeUserId: string | null; + createdAt: Date; + updatedAt: Date; +}>) { + return { + createdByUserId: null, + assigneeUserId: null, + createdAt: new Date("2026-03-06T10:00:00.000Z"), + updatedAt: new Date("2026-03-06T11:00:00.000Z"), + ...overrides, + }; +} + +describe("deriveIssueUserContext", () => { + it("marks issue unread when external comments are newer than my latest comment", () => { + const context = deriveIssueUserContext( + makeIssue({ createdByUserId: "user-1" }), + "user-1", + { + myLastCommentAt: new Date("2026-03-06T12:00:00.000Z"), + myLastReadAt: null, + lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"), + }, + ); + + expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T12:00:00.000Z"); + expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T13:00:00.000Z"); + expect(context.isUnreadForMe).toBe(true); + }); + + it("marks issue read when my latest comment is newest", () => { + const context = deriveIssueUserContext( + makeIssue({ createdByUserId: "user-1" }), + "user-1", + { + myLastCommentAt: new Date("2026-03-06T14:00:00.000Z"), + myLastReadAt: null, + lastExternalCommentAt: new Date("2026-03-06T13:00:00.000Z"), + }, + ); + + expect(context.isUnreadForMe).toBe(false); + }); + + it("uses issue creation time as fallback touch point for creator", () => { + const context = deriveIssueUserContext( + makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }), + "user-1", + { + myLastCommentAt: null, + myLastReadAt: null, + lastExternalCommentAt: new Date("2026-03-06T10:00:00.000Z"), + }, + ); + + expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T09:00:00.000Z"); + expect(context.isUnreadForMe).toBe(true); + }); + + it("uses issue updated time as fallback touch point for assignee", () => { + const context = deriveIssueUserContext( + makeIssue({ assigneeUserId: "user-1", updatedAt: new Date("2026-03-06T15:00:00.000Z") }), + "user-1", + { + myLastCommentAt: null, + myLastReadAt: null, + lastExternalCommentAt: new Date("2026-03-06T14:59:00.000Z"), + }, + ); + + expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T15:00:00.000Z"); + expect(context.isUnreadForMe).toBe(false); + }); + + it("uses latest read timestamp to clear unread without requiring a comment", () => { + const context = deriveIssueUserContext( + makeIssue({ createdByUserId: "user-1", createdAt: new Date("2026-03-06T09:00:00.000Z") }), + "user-1", + { + myLastCommentAt: null, + myLastReadAt: new Date("2026-03-06T11:30:00.000Z"), + lastExternalCommentAt: new Date("2026-03-06T11:00:00.000Z"), + }, + ); + + expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T11:30:00.000Z"); + expect(context.isUnreadForMe).toBe(false); + }); + + it("handles SQL timestamp strings without throwing", () => { + const context = deriveIssueUserContext( + makeIssue({ + createdByUserId: "user-1", + createdAt: new Date("2026-03-06T09:00:00.000Z"), + }), + "user-1", + { + myLastCommentAt: "2026-03-06T10:00:00.000Z", + myLastReadAt: null, + lastExternalCommentAt: "2026-03-06T11:00:00.000Z", + }, + ); + + expect(context.myLastTouchAt?.toISOString()).toBe("2026-03-06T10:00:00.000Z"); + expect(context.lastExternalCommentAt?.toISOString()).toBe("2026-03-06T11:00:00.000Z"); + expect(context.isUnreadForMe).toBe(true); + }); +}); diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts new file mode 100644 index 00000000..364f5a97 --- /dev/null +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -0,0 +1,493 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; + +function buildContext( + config: Record, + overrides?: Partial, +): AdapterExecutionContext { + return { + runId: "run-123", + agent: { + id: "agent-123", + companyId: "company-123", + name: "OpenClaw Gateway Agent", + adapterType: "openclaw_gateway", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: { + taskId: "task-123", + issueId: "issue-123", + wakeReason: "issue_assigned", + issueIds: ["issue-123"], + }, + onLog: async () => {}, + ...overrides, + }; +} + +async function createMockGatewayServer() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "cha" }, + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 2, + stream: "assistant", + ts: Date.now(), + data: { delta: "chacha" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function createMockGatewayServerWithPairing() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + let approved = false; + let pendingRequestId = "req-1"; + let lastSeenDeviceId: string | null = null; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + const device = frame.params?.device as Record | undefined; + const deviceId = typeof device?.id === "string" ? device.id : null; + if (deviceId) { + lastSeenDeviceId = deviceId; + } + + if (deviceId && !approved) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "NOT_PAIRED", + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: pendingRequestId, + reason: "not-paired", + }, + }, + }), + ); + socket.close(1008, "pairing required"); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { + methods: ["connect", "agent", "agent.wait", "device.pair.list", "device.pair.approve"], + events: ["agent"], + }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "device.pair.list") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + pending: approved + ? [] + : [ + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], + paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], + }, + }), + ); + return; + } + + if (frame.method === "device.pair.approve") { + const requestId = frame.params?.requestId; + if (requestId !== pendingRequestId) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "unknown requestId" }, + }), + ); + return; + } + approved = true; + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + requestId: pendingRequestId, + device: { + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayload = frame.params ?? null; + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : "run-123"; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + socket.send( + JSON.stringify({ + type: "event", + event: "agent", + payload: { + runId, + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { delta: "ok" }, + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayload: () => agentPayload, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +afterEach(() => { + // no global mocks +}); + +describe("openclaw gateway ui stdout parser", () => { + it("parses assistant deltas from gateway event lines", () => { + const ts = "2026-03-06T15:00:00.000Z"; + const line = + '[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}'; + + expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([ + { + kind: "assistant", + ts, + text: "hello", + delta: true, + }, + ]); + }); +}); + +describe("openclaw gateway adapter execute", () => { + it("runs connect -> agent -> agent.wait and forwards wake payload", async () => { + const gateway = await createMockGatewayServer(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.summary).toContain("chachacha"); + expect(result.provider).toBe("openclaw"); + + const payload = gateway.getAgentPayload(); + expect(payload).toBeTruthy(); + expect(payload?.idempotencyKey).toBe("run-123"); + expect(payload?.sessionKey).toBe("paperclip:issue:issue-123"); + expect(String(payload?.message ?? "")).toContain("wake now"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + + expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); + } finally { + await gateway.close(); + } + }); + + it("fails fast when url is missing", async () => { + const result = await execute(buildContext({})); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("openclaw_gateway_url_missing"); + }); + + it("auto-approves pairing once and retries the run", async () => { + const gateway = await createMockGatewayServerWithPairing(); + const logs: string[] = []; + + try { + const result = await execute( + buildContext( + { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2000, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + expect(result.summary).toContain("ok"); + expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe( + true, + ); + expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true); + expect(gateway.getAgentPayload()).toBeTruthy(); + } finally { + await gateway.close(); + } + }); +}); + +describe("openclaw gateway testEnvironment", () => { + it("reports missing url as failure", async () => { + const result = await testEnvironment({ + companyId: "company-123", + adapterType: "openclaw_gateway", + config: {}, + }); + + expect(result.status).toBe("fail"); + expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true); + }); +}); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts new file mode 100644 index 00000000..68cb8759 --- /dev/null +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -0,0 +1,181 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + hasPermission: vi.fn(), + canUser: vi.fn(), + isInstanceAdmin: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listMembers: vi.fn(), + setMemberPermissions: vi.fn(), + promoteInstanceAdmin: vi.fn(), + demoteInstanceAdmin: vi.fn(), + listUserCompanyAccess: vi.fn(), + setUserCompanyAccess: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2026-03-07T00:10:00.000Z"), + invitedByUserId: null, + tokenHash: "hash", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const returning = vi.fn().mockResolvedValue([createdInvite]); + const values = vi.fn().mockReturnValue({ returning }); + const insert = vi.fn().mockReturnValue({ values }); + return { + insert, + }; +} + +function createApp(actor: Record, db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/openclaw/invite-prompt", () => { + beforeEach(() => { + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects non-CEO agent callers", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + }); + + it("allows CEO agent callers and creates an agent-only invite", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({ agentMessage: "Join and configure OpenClaw gateway." }); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + expect(typeof res.body.token).toBe("string"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); + }); + + it("allows board callers with invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(true); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + }); + + it("rejects board callers without invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Permission denied"); + }); +}); diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts index c539d771..736dd9f8 100644 --- a/server/src/__tests__/opencode-local-adapter-environment.test.ts +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-opencode-local/server"; describe("opencode_local environment diagnostics", () => { - it("creates a missing working directory when cwd is absolute", async () => { + it("reports a missing working directory as an error when cwd is absolute", async () => { const cwd = path.join( os.tmpdir(), `paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => { }, }); - expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true); - expect(result.checks.some((check) => check.level === "error")).toBe(false); - const stats = await fs.stat(cwd); - expect(stats.isDirectory()).toBe(true); - await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(true); + expect(result.status).toBe("fail"); }); it("treats an empty OPENAI_API_KEY override as missing", async () => { diff --git a/server/src/__tests__/project-shortname-resolution.test.ts b/server/src/__tests__/project-shortname-resolution.test.ts new file mode 100644 index 00000000..5b0ab728 --- /dev/null +++ b/server/src/__tests__/project-shortname-resolution.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectNameForUniqueShortname } from "../services/projects.ts"; + +describe("resolveProjectNameForUniqueShortname", () => { + it("keeps name when shortname is not used", () => { + const resolved = resolveProjectNameForUniqueShortname("Platform", [ + { id: "p1", name: "Growth" }, + ]); + expect(resolved).toBe("Platform"); + }); + + it("appends numeric suffix when shortname collides", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + ]); + expect(resolved).toBe("Growth Team 2"); + }); + + it("increments suffix until unique", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "growth-team-2" }, + ]); + expect(resolved).toBe("Growth Team 3"); + }); + + it("ignores excluded project id", () => { + const resolved = resolveProjectNameForUniqueShortname( + "Growth Team", + [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "platform" }, + ], + { excludeProjectId: "p1" }, + ); + expect(resolved).toBe("Growth Team"); + }); + + it("keeps non-normalizable names unchanged", () => { + const resolved = resolveProjectNameForUniqueShortname("!!!", [ + { id: "p1", name: "growth" }, + ]); + expect(resolved).toBe("!!!"); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 57b057d1..8c56aef9 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -18,21 +18,34 @@ import { } from "@paperclipai/adapter-cursor-local/server"; import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; import { - execute as opencodeExecute, - testEnvironment as opencodeTestEnvironment, - sessionCodec as opencodeSessionCodec, + execute as openCodeExecute, + testEnvironment as openCodeTestEnvironment, + sessionCodec as openCodeSessionCodec, + listOpenCodeModels, } from "@paperclipai/adapter-opencode-local/server"; -import { agentConfigurationDoc as opencodeAgentConfigurationDoc, models as opencodeModels } from "@paperclipai/adapter-opencode-local"; import { - execute as openclawExecute, - testEnvironment as openclawTestEnvironment, -} from "@paperclipai/adapter-openclaw/server"; + agentConfigurationDoc as openCodeAgentConfigurationDoc, + models as openCodeModels, +} from "@paperclipai/adapter-opencode-local"; import { - agentConfigurationDoc as openclawAgentConfigurationDoc, - models as openclawModels, -} from "@paperclipai/adapter-openclaw"; + execute as openclawGatewayExecute, + testEnvironment as openclawGatewayTestEnvironment, +} from "@paperclipai/adapter-openclaw-gateway/server"; +import { + agentConfigurationDoc as openclawGatewayAgentConfigurationDoc, + models as openclawGatewayModels, +} from "@paperclipai/adapter-openclaw-gateway"; import { listCodexModels } from "./codex-models.js"; import { listCursorModels } from "./cursor-models.js"; +import { + execute as piExecute, + testEnvironment as piTestEnvironment, + sessionCodec as piSessionCodec, + listPiModels, +} from "@paperclipai/adapter-pi-local/server"; +import { + agentConfigurationDoc as piAgentConfigurationDoc, +} from "@paperclipai/adapter-pi-local"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -57,16 +70,6 @@ const codexLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: codexAgentConfigurationDoc, }; -const opencodeLocalAdapter: ServerAdapterModule = { - type: "opencode_local", - execute: opencodeExecute, - testEnvironment: opencodeTestEnvironment, - sessionCodec: opencodeSessionCodec, - models: opencodeModels, - supportsLocalAgentJwt: true, - agentConfigurationDoc: opencodeAgentConfigurationDoc, -}; - const cursorLocalAdapter: ServerAdapterModule = { type: "cursor", execute: cursorExecute, @@ -78,17 +81,48 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; -const openclawAdapter: ServerAdapterModule = { - type: "openclaw", - execute: openclawExecute, - testEnvironment: openclawTestEnvironment, - models: openclawModels, +const openclawGatewayAdapter: ServerAdapterModule = { + type: "openclaw_gateway", + execute: openclawGatewayExecute, + testEnvironment: openclawGatewayTestEnvironment, + models: openclawGatewayModels, supportsLocalAgentJwt: false, - agentConfigurationDoc: openclawAgentConfigurationDoc, + agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, +}; + +const openCodeLocalAdapter: ServerAdapterModule = { + type: "opencode_local", + execute: openCodeExecute, + testEnvironment: openCodeTestEnvironment, + sessionCodec: openCodeSessionCodec, + models: openCodeModels, + listModels: listOpenCodeModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: openCodeAgentConfigurationDoc, +}; + +const piLocalAdapter: ServerAdapterModule = { + type: "pi_local", + execute: piExecute, + testEnvironment: piTestEnvironment, + sessionCodec: piSessionCodec, + models: [], + listModels: listPiModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: piAgentConfigurationDoc, }; const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [ + claudeLocalAdapter, + codexLocalAdapter, + openCodeLocalAdapter, + piLocalAdapter, + cursorLocalAdapter, + openclawGatewayAdapter, + processAdapter, + httpAdapter, + ].map((a) => [a.type, a]), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/server/src/app.ts b/server/src/app.ts index 1faab285..b21ec39f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -121,6 +121,9 @@ export async function createApp( }), ); app.use("/api", api); + app.use("/api", (_req, res) => { + res.status(404).json({ error: "API route not found" }); + }); const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (opts.uiMode === "static") { @@ -131,9 +134,10 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { + const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"); app.use(express.static(uiDist)); app.get(/.*/, (_req, res) => { - res.sendFile(path.join(uiDist, "index.html")); + res.status(200).set("Content-Type", "text/html").end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index db117a00..786d3a4b 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -42,13 +42,38 @@ function headersFromExpressRequest(req: Request): Headers { return headersFromNodeHeaders(req.headers); } -export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance { +export function deriveAuthTrustedOrigins(config: Config): string[] { + const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const trustedOrigins = new Set(); + + if (baseUrl) { + try { + trustedOrigins.add(new URL(baseUrl).origin); + } catch { + // Better Auth will surface invalid base URL separately. + } + } + if (config.deploymentMode === "authenticated") { + for (const hostname of config.allowedHostnames) { + const trimmed = hostname.trim().toLowerCase(); + if (!trimmed) continue; + trustedOrigins.add(`https://${trimmed}`); + trustedOrigins.add(`http://${trimmed}`); + } + } + + return Array.from(trustedOrigins); +} + +export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins?: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; + const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config); const authConfig = { baseURL: baseUrl, secret, + trustedOrigins: effectiveTrustedOrigins, database: drizzleAdapter(db, { provider: "pg", schema: { @@ -61,6 +86,7 @@ export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInst emailAndPassword: { enabled: true, requireEmailVerification: false, + disableSignUp: config.authDisableSignUp, }, }; diff --git a/server/src/config.ts b/server/src/config.ts index 01a37588..983eba22 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -37,6 +37,7 @@ export interface Config { allowedHostnames: string[]; authBaseUrlMode: AuthBaseUrlMode; authPublicBaseUrl: string | undefined; + authDisableSignUp: boolean; databaseMode: DatabaseMode; databaseUrl: string | undefined; embeddedPostgresDataDir: string; @@ -130,15 +131,23 @@ export function loadConfig(): Config { AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) : null; + const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL; const authPublicBaseUrlRaw = process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + publicUrlFromEnv ?? fileConfig?.auth?.publicBaseUrl; const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; const authBaseUrlMode: AuthBaseUrlMode = authBaseUrlModeFromEnv ?? fileConfig?.auth?.baseUrlMode ?? (authPublicBaseUrl ? "explicit" : "auto"); + const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP; + const authDisableSignUp: boolean = + disableSignUpFromEnv !== undefined + ? disableSignUpFromEnv === "true" + : (fileConfig?.auth?.disableSignUp ?? false); const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES; const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw ? allowedHostnamesFromEnvRaw @@ -146,8 +155,24 @@ export function loadConfig(): Config { .map((value) => value.trim().toLowerCase()) .filter((value) => value.length > 0) : null; + const publicUrlHostname = authPublicBaseUrl + ? (() => { + try { + return new URL(authPublicBaseUrl).hostname.trim().toLowerCase(); + } catch { + return null; + } + })() + : null; const allowedHostnames = Array.from( - new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)), + new Set( + [ + ...(allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []), + ...(publicUrlHostname ? [publicUrlHostname] : []), + ] + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ), ); const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION; const companyDeletionEnabled = @@ -184,6 +209,7 @@ export function loadConfig(): Config { allowedHostnames, authBaseUrlMode, authPublicBaseUrl, + authDisableSignUp, databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, embeddedPostgresDataDir: resolveHomeAwarePath( diff --git a/server/src/index.ts b/server/src/index.ts index ada5743f..e78a6479 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -412,6 +412,7 @@ if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, createBetterAuthInstance, + deriveAuthTrustedOrigins, resolveBetterAuthSession, resolveBetterAuthSessionFromHeaders, } = await import("./auth/better-auth.js"); @@ -422,7 +423,25 @@ if (config.deploymentMode === "authenticated") { "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", ); } - const auth = createBetterAuthInstance(db as any, config); + const derivedTrustedOrigins = deriveAuthTrustedOrigins(config); + const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins])); + logger.info( + { + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl ?? null, + trustedOrigins: effectiveTrustedOrigins, + trustedOriginsSource: { + derived: derivedTrustedOrigins.length, + env: envTrustedOrigins.length, + }, + }, + "Authenticated mode auth origin configuration", + ); + const auth = createBetterAuthInstance(db as any, config, effectiveTrustedOrigins); betterAuthHandler = createBetterAuthHandler(auth); resolveSession = (req) => resolveBetterAuthSession(auth, req); resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); @@ -444,7 +463,7 @@ const app = await createApp(db as any, { betterAuthHandler, resolveSession, }); -const server = createServer(app); +const server = createServer(app as unknown as Parameters[0]); const listenPort = await detectPort(config.port); if (listenPort !== config.port) { diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index e9d6c5a8..7f86dfd0 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -1,8 +1,35 @@ import type { Request, Response, NextFunction } from "express"; import { ZodError } from "zod"; -import { logger } from "./logger.js"; import { HttpError } from "../errors.js"; +export interface ErrorContext { + error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown }; + method: string; + url: string; + reqBody?: unknown; + reqParams?: unknown; + reqQuery?: unknown; +} + +function attachErrorContext( + req: Request, + res: Response, + payload: ErrorContext["error"], + rawError?: Error, +) { + (res as any).__errorContext = { + error: payload, + method: req.method, + url: req.originalUrl, + reqBody: req.body, + reqParams: req.params, + reqQuery: req.query, + } satisfies ErrorContext; + if (rawError) { + (res as any).err = rawError; + } +} + export function errorHandler( err: unknown, req: Request, @@ -10,6 +37,14 @@ export function errorHandler( _next: NextFunction, ) { if (err instanceof HttpError) { + if (err.status >= 500) { + attachErrorContext( + req, + res, + { message: err.message, stack: err.stack, name: err.name, details: err.details }, + err, + ); + } res.status(err.status).json({ error: err.message, ...(err.details ? { details: err.details } : {}), @@ -22,19 +57,15 @@ export function errorHandler( return; } - const errObj = err instanceof Error - ? { message: err.message, stack: err.stack, name: err.name } - : { raw: err }; - - // Attach the real error so pino-http can include it in its response log - res.locals.serverError = errObj; - - logger.error( - { err: errObj, method: req.method, url: req.originalUrl }, - "Unhandled error: %s %s — %s", - req.method, - req.originalUrl, - err instanceof Error ? err.message : String(err), + const rootError = err instanceof Error ? err : new Error(String(err)); + attachErrorContext( + req, + res, + err instanceof Error + ? { message: err.message, stack: err.stack, name: err.name } + : { message: String(err), raw: err, stack: rootError.stack, name: rootError.name }, + rootError, ); + res.status(500).json({ error: "Internal server error" }); } diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 8f915b85..be47e3c5 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -52,13 +52,37 @@ export const httpLogger = pinoHttp({ customSuccessMessage(req, res) { return `${req.method} ${req.url} ${res.statusCode}`; }, - customErrorMessage(req, res) { - return `${req.method} ${req.url} ${res.statusCode}`; + customErrorMessage(req, res, err) { + const ctx = (res as any).__errorContext; + const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error"; + return `${req.method} ${req.url} ${res.statusCode} — ${errMsg}`; }, - customProps(_req, res) { - const serverError = (res as any).locals?.serverError; - if (serverError) { - return { serverError }; + customProps(req, res) { + if (res.statusCode >= 400) { + const ctx = (res as any).__errorContext; + if (ctx) { + return { + errorContext: ctx.error, + reqBody: ctx.reqBody, + reqParams: ctx.reqParams, + reqQuery: ctx.reqQuery, + }; + } + const props: Record = {}; + const { body, params, query } = req as any; + if (body && typeof body === "object" && Object.keys(body).length > 0) { + props.reqBody = body; + } + if (params && typeof params === "object" && Object.keys(params).length > 0) { + props.reqParams = params; + } + if (query && typeof query === "object" && Object.keys(query).length > 0) { + props.reqQuery = query; + } + if ((req as any).route?.path) { + props.routePath = (req as any).route.path; + } + return props; } return {}; }, diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index b082ecb6..d18e2930 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -1,15 +1,45 @@ import { createHash } from "node:crypto"; import type { IncomingMessage, Server as HttpServer } from "node:http"; +import { createRequire } from "node:module"; import type { Duplex } from "node:stream"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import type { DeploymentMode } from "@paperclipai/shared"; -import { WebSocket, WebSocketServer } from "ws"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "../middleware/logger.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; +interface WsSocket { + readyState: number; + ping(): void; + send(data: string): void; + terminate(): void; + close(code?: number, reason?: string): void; + on(event: "pong", listener: () => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; +} + +interface WsServer { + clients: Set; + on(event: "connection", listener: (socket: WsSocket, req: IncomingMessage) => void): void; + on(event: "close", listener: () => void): void; + handleUpgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + callback: (ws: WsSocket) => void, + ): void; + emit(event: "connection", ws: WsSocket, req: IncomingMessage): boolean; +} + +const require = createRequire(import.meta.url); +const { WebSocket, WebSocketServer } = require("ws") as { + WebSocket: { OPEN: number }; + WebSocketServer: new (opts: { noServer: boolean }) => WsServer; +}; + interface UpgradeContext { companyId: string; actorType: "board" | "agent"; @@ -154,8 +184,8 @@ export function setupLiveEventsWebSocketServer( }, ) { const wss = new WebSocketServer({ noServer: true }); - const cleanupByClient = new Map void>(); - const aliveByClient = new Map(); + const cleanupByClient = new Map void>(); + const aliveByClient = new Map(); const pingInterval = setInterval(() => { for (const socket of wss.clients) { @@ -168,7 +198,7 @@ export function setupLiveEventsWebSocketServer( } }, 30000); - wss.on("connection", (socket, req) => { + wss.on("connection", (socket: WsSocket, req: IncomingMessage) => { const context = (req as IncomingMessageWithContext).paperclipUpgradeContext; if (!context) { socket.close(1008, "missing context"); @@ -194,7 +224,7 @@ export function setupLiveEventsWebSocketServer( aliveByClient.delete(socket); }); - socket.on("error", (err) => { + socket.on("error", (err: Error) => { logger.warn({ err, companyId: context.companyId }, "live websocket client error"); }); }); @@ -229,7 +259,7 @@ export function setupLiveEventsWebSocketServer( const reqWithContext = req as IncomingMessageWithContext; reqWithContext.paperclipUpgradeContext = context; - wss.handleUpgrade(req, socket, head, (ws) => { + wss.handleUpgrade(req, socket, head, (ws: WsSocket) => { wss.emit("connection", ws, reqWithContext); }); }) diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 27e659e4..c13366ff 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1,4 +1,9 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { + createHash, + generateKeyPairSync, + randomBytes, + timingSafeEqual +} from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,58 +15,95 @@ import { agentApiKeys, authUsers, invites, - joinRequests, + joinRequests } from "@paperclipai/db"; import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, - PERMISSION_KEYS, + PERMISSION_KEYS } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; -import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; +import { + forbidden, + conflict, + notFound, + unauthorized, + badRequest +} from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; -import { accessService, agentService, logActivity } from "../services/index.js"; +import { + accessService, + agentService, + deduplicateAgentName, + logActivity, + notifyHireApproved +} from "../services/index.js"; import { assertCompanyAccess } from "./authz.js"; -import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { + claimBoardOwnership, + inspectBoardClaimChallenge +} from "../board-claim.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } +const INVITE_TOKEN_PREFIX = "pcp_invite_"; +const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; +const INVITE_TOKEN_SUFFIX_LENGTH = 8; +const INVITE_TOKEN_MAX_RETRIES = 5; +const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000; + function createInviteToken() { - return `pcp_invite_${randomBytes(24).toString("hex")}`; + const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH); + let suffix = ""; + for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) { + suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length]; + } + return `${INVITE_TOKEN_PREFIX}${suffix}`; } function createClaimSecret() { return `pcp_claim_${randomBytes(24).toString("hex")}`; } +export function companyInviteExpiresAt(nowMs: number = Date.now()) { + return new Date(nowMs + COMPANY_INVITE_TTL_MS); +} + function tokenHashesMatch(left: string, right: string) { const leftBytes = Buffer.from(left, "utf8"); const rightBytes = Buffer.from(right, "utf8"); - return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); + return ( + leftBytes.length === rightBytes.length && + timingSafeEqual(leftBytes, rightBytes) + ); } function requestBaseUrl(req: Request) { const forwardedProto = req.header("x-forwarded-proto"); const proto = forwardedProto?.split(",")[0]?.trim() || req.protocol || "http"; - const host = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host"); + const host = + req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host"); if (!host) return ""; return `${proto}://${host}`; } function readSkillMarkdown(skillName: string): string | null { const normalized = skillName.trim().toLowerCase(); - if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") return null; + if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") + return null; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ - path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> /skills/ - path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root) - path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md"), // dev: src/routes/ -> repo root/skills/ + path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> /skills/ + path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root) + path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/ ]; for (const skillPath of candidates) { try { @@ -100,93 +142,369 @@ function normalizeHostname(value: string | null | undefined): string | null { if (!trimmed) return null; if (trimmed.startsWith("[")) { const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); + return end > 1 + ? trimmed.slice(1, end).toLowerCase() + : trimmed.toLowerCase(); } const firstColon = trimmed.indexOf(":"); if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); return trimmed.toLowerCase(); } -function normalizeHeaderMap(input: unknown): Record | undefined { - if (!isPlainObject(input)) return undefined; +function normalizeHeaderValue( + value: unknown, + depth: number = 0 +): string | null { + const direct = nonEmptyTrimmedString(value); + if (direct) return direct; + if (!isPlainObject(value) || depth >= 3) return null; + + const candidateKeys = [ + "value", + "token", + "secret", + "apiKey", + "api_key", + "auth", + "authToken", + "auth_token", + "accessToken", + "access_token", + "authorization", + "bearer", + "header", + "raw", + "text", + "string" + ]; + for (const key of candidateKeys) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + const normalized = normalizeHeaderValue( + (value as Record)[key], + depth + 1 + ); + if (normalized) return normalized; + } + + const entries = Object.entries(value as Record); + if (entries.length === 1) { + const [singleKey, singleValue] = entries[0]; + const normalizedKey = singleKey.trim().toLowerCase(); + if ( + normalizedKey !== "type" && + normalizedKey !== "version" && + normalizedKey !== "secretid" && + normalizedKey !== "secret_id" + ) { + const normalized = normalizeHeaderValue(singleValue, depth + 1); + if (normalized) return normalized; + } + } + + return null; +} + +function extractHeaderEntries(input: unknown): Array<[string, unknown]> { + if (isPlainObject(input)) { + return Object.entries(input); + } + if (!Array.isArray(input)) { + return []; + } + + const entries: Array<[string, unknown]> = []; + for (const item of input) { + if (Array.isArray(item)) { + const key = nonEmptyTrimmedString(item[0]); + if (!key) continue; + entries.push([key, item[1]]); + continue; + } + if (!isPlainObject(item)) continue; + + const mapped = item as Record; + const explicitKey = + nonEmptyTrimmedString(mapped.key) ?? + nonEmptyTrimmedString(mapped.name) ?? + nonEmptyTrimmedString(mapped.header); + if (explicitKey) { + const explicitValue = Object.prototype.hasOwnProperty.call( + mapped, + "value" + ) + ? mapped.value + : Object.prototype.hasOwnProperty.call(mapped, "token") + ? mapped.token + : Object.prototype.hasOwnProperty.call(mapped, "secret") + ? mapped.secret + : mapped; + entries.push([explicitKey, explicitValue]); + continue; + } + + const singleEntry = Object.entries(mapped); + if (singleEntry.length === 1) { + entries.push(singleEntry[0] as [string, unknown]); + } + } + + return entries; +} + +function normalizeHeaderMap( + input: unknown +): Record | undefined { + const entries = extractHeaderEntries(input); + if (entries.length === 0) return undefined; + const out: Record = {}; - for (const [key, value] of Object.entries(input)) { - if (typeof value !== "string") continue; + for (const [key, value] of entries) { + const normalizedValue = normalizeHeaderValue(value); + if (!normalizedValue) continue; const trimmedKey = key.trim(); - const trimmedValue = value.trim(); + const trimmedValue = normalizedValue.trim(); if (!trimmedKey || !trimmedValue) continue; out[trimmedKey] = trimmedValue; } return Object.keys(out).length > 0 ? out : undefined; } -function buildJoinConnectivityDiagnostics(input: { - deploymentMode: DeploymentMode; - deploymentExposure: DeploymentExposure; - bindHost: string; - allowedHostnames: string[]; - callbackUrl: URL | null; -}): JoinDiagnostic[] { - const diagnostics: JoinDiagnostic[] = []; - const bindHost = normalizeHostname(input.bindHost); - const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null; - const allowSet = new Set( - input.allowedHostnames - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - - diagnostics.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`, - }); - - if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") { - if (!bindHost || isLoopbackHost(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.", - }); - } - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_not_allowed", - level: "warn", - message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`, - }); - } - if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { - diagnostics.push({ - code: "openclaw_private_allowed_hostnames_empty", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host.", - }); - } - } - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "public" && - input.callbackUrl && - input.callbackUrl.protocol !== "https:" - ) { - diagnostics.push({ - code: "openclaw_public_http_callback", - level: "warn", - message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", - }); - } - - return diagnostics; +function nonEmptyTrimmedString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } -function normalizeAgentDefaultsForJoin(input: { +function headerMapHasKeyIgnoreCase( + headers: Record, + targetKey: string +): boolean { + const normalizedTarget = targetKey.trim().toLowerCase(); + return Object.keys(headers).some( + (key) => key.trim().toLowerCase() === normalizedTarget + ); +} + +function headerMapGetIgnoreCase( + headers: Record, + targetKey: string +): string | null { + const normalizedTarget = targetKey.trim().toLowerCase(); + const key = Object.keys(headers).find( + (candidate) => candidate.trim().toLowerCase() === normalizedTarget + ); + if (!key) return null; + const value = headers[key]; + return typeof value === "string" ? value : null; +} + +function tokenFromAuthorizationHeader(rawHeader: string | null): string | null { + const trimmed = nonEmptyTrimmedString(rawHeader); + if (!trimmed) return null; + const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i); + if (bearerMatch?.[1]) { + return nonEmptyTrimmedString(bearerMatch[1]); + } + return trimmed; +} + +function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + return null; +} + +function generateEd25519PrivateKeyPem(): string { + const generated = generateKeyPairSync("ed25519"); + return generated.privateKey + .export({ type: "pkcs8", format: "pem" }) + .toString(); +} + +export function buildJoinDefaultsPayloadForAccept(input: { + adapterType: string | null; + defaultsPayload: unknown; + paperclipApiUrl?: unknown; + inboundOpenClawAuthHeader?: string | null; + inboundOpenClawTokenHeader?: string | null; +}): unknown { + if (input.adapterType !== "openclaw_gateway") { + return input.defaultsPayload; + } + + const merged = isPlainObject(input.defaultsPayload) + ? { ...(input.defaultsPayload as Record) } + : ({} as Record); + + if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { + const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); + if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; + } + const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; + + const inboundOpenClawAuthHeader = nonEmptyTrimmedString( + input.inboundOpenClawAuthHeader + ); + const inboundOpenClawTokenHeader = nonEmptyTrimmedString( + input.inboundOpenClawTokenHeader + ); + if ( + inboundOpenClawTokenHeader && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader; + } + if ( + inboundOpenClawAuthHeader && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth") + ) { + mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader; + } + + if (Object.keys(mergedHeaders).length > 0) { + merged.headers = mergedHeaders; + } else { + delete merged.headers; + } + + const discoveredToken = + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(mergedHeaders, "authorization") + ); + if ( + discoveredToken && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = discoveredToken; + } + + return Object.keys(merged).length > 0 ? merged : null; +} + +export function mergeJoinDefaultsPayloadForReplay( + existingDefaultsPayload: unknown, + nextDefaultsPayload: unknown +): unknown { + if ( + !isPlainObject(existingDefaultsPayload) && + !isPlainObject(nextDefaultsPayload) + ) { + return nextDefaultsPayload ?? existingDefaultsPayload; + } + if (!isPlainObject(existingDefaultsPayload)) { + return nextDefaultsPayload; + } + if (!isPlainObject(nextDefaultsPayload)) { + return existingDefaultsPayload; + } + + const merged: Record = { + ...(existingDefaultsPayload as Record), + ...(nextDefaultsPayload as Record) + }; + + const existingHeaders = normalizeHeaderMap( + (existingDefaultsPayload as Record).headers + ); + const nextHeaders = normalizeHeaderMap( + (nextDefaultsPayload as Record).headers + ); + if (existingHeaders || nextHeaders) { + merged.headers = { + ...(existingHeaders ?? {}), + ...(nextHeaders ?? {}) + }; + } else if (Object.prototype.hasOwnProperty.call(merged, "headers")) { + delete merged.headers; + } + + return merged; +} + +export function canReplayOpenClawGatewayInviteAccept(input: { + requestType: "human" | "agent"; + adapterType: string | null; + existingJoinRequest: Pick< + typeof joinRequests.$inferSelect, + "requestType" | "adapterType" | "status" + > | null; +}): boolean { + if ( + input.requestType !== "agent" || + input.adapterType !== "openclaw_gateway" + ) { + return false; + } + if (!input.existingJoinRequest) { + return false; + } + if ( + input.existingJoinRequest.requestType !== "agent" || + input.existingJoinRequest.adapterType !== "openclaw_gateway" + ) { + return false; + } + return ( + input.existingJoinRequest.status === "pending_approval" || + input.existingJoinRequest.status === "approved" + ); +} + +function summarizeSecretForLog( + value: unknown +): { present: true; length: number; sha256Prefix: string } | null { + const trimmed = nonEmptyTrimmedString(value); + if (!trimmed) return null; + return { + present: true, + length: trimmed.length, + sha256Prefix: hashToken(trimmed).slice(0, 12) + }; +} + +function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { + const defaults = isPlainObject(defaultsPayload) + ? (defaultsPayload as Record) + : null; + const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; + const gatewayTokenValue = headers + ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(headers, "authorization") + ) + : null; + return { + present: Boolean(defaults), + keys: defaults ? Object.keys(defaults).sort() : [], + url: defaults ? nonEmptyTrimmedString(defaults.url) : null, + paperclipApiUrl: defaults + ? nonEmptyTrimmedString(defaults.paperclipApiUrl) + : null, + headerKeys: headers ? Object.keys(headers).sort() : [], + sessionKeyStrategy: defaults + ? nonEmptyTrimmedString(defaults.sessionKeyStrategy) + : null, + disableDeviceAuth: defaults + ? parseBooleanLike(defaults.disableDeviceAuth) + : null, + waitTimeoutMs: + defaults && typeof defaults.waitTimeoutMs === "number" + ? defaults.waitTimeoutMs + : null, + devicePrivateKeyPem: defaults + ? summarizeSecretForLog(defaults.devicePrivateKeyPem) + : null, + gatewayToken: summarizeSecretForLog(gatewayTokenValue) + }; +} + +export function normalizeAgentDefaultsForJoin(input: { adapterType: string | null; defaultsPayload: unknown; deploymentMode: DeploymentMode; @@ -194,106 +512,260 @@ function normalizeAgentDefaultsForJoin(input: { bindHost: string; allowedHostnames: string[]; }) { + const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; - if (input.adapterType !== "openclaw") { + if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record) : null; - return { normalized, diagnostics }; + return { normalized, diagnostics, fatalErrors }; } if (!isPlainObject(input.defaultsPayload)) { diagnostics.push({ - code: "openclaw_callback_config_missing", + code: "openclaw_gateway_defaults_missing", level: "warn", - message: "No OpenClaw callback config was provided in agentDefaultsPayload.", - hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.", + message: + "No OpenClaw gateway config was provided in agentDefaultsPayload.", + hint: + "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins." }); - return { normalized: null as Record | null, diagnostics }; + fatalErrors.push( + "agentDefaultsPayload is required for adapterType=openclaw_gateway" + ); + return { + normalized: null as Record | null, + diagnostics, + fatalErrors + }; } const defaults = input.defaultsPayload as Record; const normalized: Record = {}; - let callbackUrl: URL | null = null; - const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : ""; - if (!rawUrl) { + let gatewayUrl: URL | null = null; + const rawGatewayUrl = nonEmptyTrimmedString(defaults.url); + if (!rawGatewayUrl) { diagnostics.push({ - code: "openclaw_callback_url_missing", + code: "openclaw_gateway_url_missing", level: "warn", - message: "OpenClaw callback URL is missing.", - hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.", + message: "OpenClaw gateway URL is missing.", + hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL." }); + fatalErrors.push("agentDefaultsPayload.url is required"); } else { try { - callbackUrl = new URL(rawUrl); - if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") { + gatewayUrl = new URL(rawGatewayUrl); + if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") { diagnostics.push({ - code: "openclaw_callback_url_protocol", + code: "openclaw_gateway_url_protocol", level: "warn", - message: `Unsupported callback protocol: ${callbackUrl.protocol}`, - hint: "Use http:// or https://.", + message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).` }); + fatalErrors.push( + "agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway" + ); } else { - normalized.url = callbackUrl.toString(); + normalized.url = gatewayUrl.toString(); diagnostics.push({ - code: "openclaw_callback_url_configured", + code: "openclaw_gateway_url_configured", level: "info", - message: `Callback endpoint set to ${callbackUrl.toString()}`, - }); - } - if (isLoopbackHost(callbackUrl.hostname)) { - diagnostics.push({ - code: "openclaw_callback_loopback", - level: "warn", - message: "OpenClaw callback endpoint uses loopback hostname.", - hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.", + message: `Gateway endpoint set to ${gatewayUrl.toString()}` }); } } catch { diagnostics.push({ - code: "openclaw_callback_url_invalid", + code: "openclaw_gateway_url_invalid", level: "warn", - message: `Invalid callback URL: ${rawUrl}`, + message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}` }); + fatalErrors.push("agentDefaultsPayload.url is not a valid URL"); } } - const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : ""; - normalized.method = rawMethod || "POST"; - - if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) { - normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec))); + const headers = normalizeHeaderMap(defaults.headers) ?? {}; + const gatewayToken = + headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization")); + if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) { + headers["x-openclaw-token"] = gatewayToken; + } + if (Object.keys(headers).length > 0) { + normalized.headers = headers; } - const headers = normalizeHeaderMap(defaults.headers); - if (headers) normalized.headers = headers; - - if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) { - normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); + if (!gatewayToken) { + diagnostics.push({ + code: "openclaw_gateway_auth_header_missing", + level: "warn", + message: "Gateway auth token is missing from agent defaults.", + hint: + "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required" + ); + } else if (gatewayToken.trim().length < 16) { + diagnostics.push({ + code: "openclaw_gateway_auth_header_too_short", + level: "warn", + message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`, + hint: + "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token" + ); + } else { + diagnostics.push({ + code: "openclaw_gateway_auth_header_configured", + level: "info", + message: "Gateway auth token configured." + }); } if (isPlainObject(defaults.payloadTemplate)) { normalized.payloadTemplate = defaults.payloadTemplate; } - diagnostics.push( - ...buildJoinConnectivityDiagnostics({ - deploymentMode: input.deploymentMode, - deploymentExposure: input.deploymentExposure, - bindHost: input.bindHost, - allowedHostnames: input.allowedHostnames, - callbackUrl, - }), - ); + const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth); + const disableDeviceAuth = parsedDisableDeviceAuth === true; + if (parsedDisableDeviceAuth !== null) { + normalized.disableDeviceAuth = parsedDisableDeviceAuth; + } - return { normalized, diagnostics }; + const configuredDevicePrivateKeyPem = nonEmptyTrimmedString( + defaults.devicePrivateKeyPem + ); + if (configuredDevicePrivateKeyPem) { + normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem; + diagnostics.push({ + code: "openclaw_gateway_device_key_configured", + level: "info", + message: + "Gateway device key configured. Pairing approvals should persist for this agent." + }); + } else if (!disableDeviceAuth) { + try { + normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem(); + diagnostics.push({ + code: "openclaw_gateway_device_key_generated", + level: "info", + message: + "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent." + }); + } catch (err) { + diagnostics.push({ + code: "openclaw_gateway_device_key_generate_failed", + level: "warn", + message: `Failed to generate gateway device key: ${ + err instanceof Error ? err.message : String(err) + }`, + hint: + "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true." + }); + fatalErrors.push( + "Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true." + ); + } + } + + const waitTimeoutMs = + typeof defaults.waitTimeoutMs === "number" && + Number.isFinite(defaults.waitTimeoutMs) + ? Math.floor(defaults.waitTimeoutMs) + : typeof defaults.waitTimeoutMs === "string" + ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) + : NaN; + if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) { + normalized.waitTimeoutMs = waitTimeoutMs; + } + + const timeoutSec = + typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec) + ? Math.floor(defaults.timeoutSec) + : typeof defaults.timeoutSec === "string" + ? Number.parseInt(defaults.timeoutSec.trim(), 10) + : NaN; + if (Number.isFinite(timeoutSec) && timeoutSec > 0) { + normalized.timeoutSec = timeoutSec; + } + + const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy); + if ( + sessionKeyStrategy === "fixed" || + sessionKeyStrategy === "issue" || + sessionKeyStrategy === "run" + ) { + normalized.sessionKeyStrategy = sessionKeyStrategy; + } + + const sessionKey = nonEmptyTrimmedString(defaults.sessionKey); + if (sessionKey) { + normalized.sessionKey = sessionKey; + } + + const role = nonEmptyTrimmedString(defaults.role); + if (role) { + normalized.role = role; + } + + if (Array.isArray(defaults.scopes)) { + const scopes = defaults.scopes + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + if (scopes.length > 0) { + normalized.scopes = scopes; + } + } + + const rawPaperclipApiUrl = + typeof defaults.paperclipApiUrl === "string" + ? defaults.paperclipApiUrl.trim() + : ""; + if (rawPaperclipApiUrl) { + try { + const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl); + if ( + parsedPaperclipApiUrl.protocol !== "http:" && + parsedPaperclipApiUrl.protocol !== "https:" + ) { + diagnostics.push({ + code: "openclaw_gateway_paperclip_api_url_protocol", + level: "warn", + message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).` + }); + } else { + normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); + diagnostics.push({ + code: "openclaw_gateway_paperclip_api_url_configured", + level: "info", + message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` + }); + } + } catch { + diagnostics.push({ + code: "openclaw_gateway_paperclip_api_url_invalid", + level: "warn", + message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` + }); + } + } + + return { normalized, diagnostics, fatalErrors }; } -function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) { +function toInviteSummaryResponse( + req: Request, + token: string, + invite: typeof invites.$inferSelect +) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const inviteMessage = extractInviteMessage(invite); return { id: invite.id, companyId: invite.companyId, @@ -303,9 +775,14 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, - onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, + onboardingTextUrl: baseUrl + ? `${baseUrl}${onboardingTextPath}` + : onboardingTextPath, skillIndexPath: "/api/skills/index", - skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index", + skillIndexUrl: baseUrl + ? `${baseUrl}/api/skills/index` + : "/api/skills/index", + inviteMessage }; } @@ -330,7 +807,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { const allowSet = new Set( input.allowedHostnames .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), + .filter((entry): entry is string => Boolean(entry)) ); if (apiHost && isLoopbackHost(apiHost)) { @@ -339,7 +816,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { level: "warn", message: "Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain).", + hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)." }); } @@ -352,7 +829,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { code: "openclaw_onboarding_private_loopback_bind", level: "warn", message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding.", + hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding." }); } @@ -368,13 +845,53 @@ function buildOnboardingDiscoveryDiagnostics(input: { code: "openclaw_onboarding_private_host_not_allowed", level: "warn", message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`, - hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`, + hint: `Run pnpm paperclipai allowed-hostname ${apiHost}` }); } return diagnostics; } +function buildOnboardingConnectionCandidates(input: { + apiBaseUrl: string; + bindHost: string; + allowedHostnames: string[]; +}): string[] { + let base: URL | null = null; + try { + if (input.apiBaseUrl) { + base = new URL(input.apiBaseUrl); + } + } catch { + base = null; + } + + const protocol = base?.protocol ?? "http:"; + const port = base?.port ? `:${base.port}` : ""; + const candidates = new Set(); + + if (base) { + candidates.add(base.origin); + } + + const bindHost = normalizeHostname(input.bindHost); + if (bindHost && !isLoopbackHost(bindHost)) { + candidates.add(`${protocol}//${bindHost}${port}`); + } + + for (const rawHost of input.allowedHostnames) { + const host = normalizeHostname(rawHost); + if (!host) continue; + candidates.add(`${protocol}//${host}${port}`); + } + + if (base && isLoopbackHost(base.hostname)) { + candidates.add(`${protocol}//host.docker.internal${port}`); + } + + return Array.from(candidates); +} + function buildInviteOnboardingManifest( req: Request, token: string, @@ -384,72 +901,85 @@ function buildInviteOnboardingManifest( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const baseUrl = requestBaseUrl(req); const skillPath = "/api/skills/paperclip"; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; const registrationEndpointPath = `/api/invites/${token}/accept`; - const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath; + const registrationEndpointUrl = baseUrl + ? `${baseUrl}${registrationEndpointPath}` + : registrationEndpointPath; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; - const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath; + const onboardingTextUrl = baseUrl + ? `${baseUrl}${onboardingTextPath}` + : onboardingTextPath; const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({ apiBaseUrl: baseUrl, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames, + allowedHostnames: opts.allowedHostnames + }); + const connectionCandidates = buildOnboardingConnectionCandidates({ + apiBaseUrl: baseUrl, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames }); return { invite: toInviteSummaryResponse(req, token, invite), onboarding: { instructions: - "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.", - recommendedAdapterType: "openclaw", + "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", + inviteMessage: extractInviteMessage(invite), + recommendedAdapterType: "openclaw_gateway", requiredFields: { requestType: "agent", agentName: "Display name for this agent", - adapterType: "Use 'openclaw' for OpenClaw webhook-based agents", + adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents", capabilities: "Optional capability summary", agentDefaultsPayload: - "Optional adapter config such as url/method/headers/webhookAuthHeader for OpenClaw callback endpoint", + "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem." }, registrationEndpoint: { method: "POST", path: registrationEndpointPath, - url: registrationEndpointUrl, + url: registrationEndpointUrl }, claimEndpointTemplate: { method: "POST", path: "/api/join-requests/{requestId}/claim-api-key", body: { - claimSecret: "one-time claim secret returned when the join request is created", - }, + claimSecret: + "one-time claim secret returned when the join request is created" + } }, connectivity: { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, allowedHostnames: opts.allowedHostnames, + connectionCandidates, diagnostics: discoveryDiagnostics, guidance: - opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private" + opts.deploymentMode === "authenticated" && + opts.deploymentExposure === "private" ? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname `." - : "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.", + : "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls." }, textInstructions: { path: onboardingTextPath, url: onboardingTextUrl, - contentType: "text/plain", + contentType: "text/plain" }, skill: { name: "paperclip", path: skillPath, url: skillUrl, - installPath: "~/.openclaw/skills/paperclip/SKILL.md", - }, - }, + installPath: "~/.openclaw/skills/paperclip/SKILL.md" + } + } }; } @@ -462,99 +992,265 @@ export function buildInviteOnboardingTextDocument( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const manifest = buildInviteOnboardingManifest(req, token, invite, opts); const onboarding = manifest.onboarding as { + inviteMessage?: string | null; registrationEndpoint: { method: string; path: string; url: string }; claimEndpointTemplate: { method: string; path: string }; textInstructions: { path: string; url: string }; skill: { path: string; url: string; installPath: string }; - connectivity: { diagnostics?: JoinDiagnostic[]; guidance?: string }; + connectivity: { + diagnostics?: JoinDiagnostic[]; + guidance?: string; + connectionCandidates?: string[]; + testResolutionEndpoint?: { method?: string; path?: string; url?: string }; + }; }; const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics) ? onboarding.connectivity.diagnostics : []; - const lines = [ - "# Paperclip OpenClaw Onboarding", - "", - "This document is meant to be readable by both humans and agents.", - "", - "## Invite", - `- inviteType: ${invite.inviteType}`, - `- allowedJoinTypes: ${invite.allowedJoinTypes}`, - `- expiresAt: ${invite.expiresAt.toISOString()}`, - "", - "## Step 1: Submit agent join request", - `${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`, - "", - "Body (JSON):", - "{", - ' "requestType": "agent",', - ' "agentName": "My OpenClaw Agent",', - ' "adapterType": "openclaw",', - ' "capabilities": "Optional summary",', - ' "agentDefaultsPayload": {', - ' "url": "https://your-openclaw-webhook.example/webhook",', - ' "method": "POST",', - ' "headers": { "x-openclaw-auth": "replace-me" },', - ' "timeoutSec": 30', - " }", - "}", - "", - "Expected response includes:", - "- request id", - "- one-time claimSecret", - "- claimApiKeyPath", - "", - "## Step 2: Wait for board approval", - "The board approves the join request in Paperclip before key claim is allowed.", - "", - "## Step 3: Claim API key (one-time)", - `${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key`, - "", - "Body (JSON):", - "{", - ' "claimSecret": ""', - "}", - "", - "Important:", - "- claim secrets expire", - "- claim secrets are single-use", - "- claim fails before board approval", - "", - "## Step 4: Install Paperclip skill in OpenClaw", - `GET ${onboarding.skill.url}`, - `Install path: ${onboarding.skill.installPath}`, - "", - "## Text onboarding URL", - `${onboarding.textInstructions.url}`, - "", - "## Connectivity guidance", - onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.", - ]; + const lines: string[] = []; + const appendBlock = (block: string) => { + const trimmed = block.replace(/^\n/, "").replace(/\n\s*$/, ""); + const lineIndentation = trimmed + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^(\s*)/)?.[0].length ?? 0); + const minIndent = + lineIndentation.length > 0 ? Math.min(...lineIndentation) : 0; + for (const line of trimmed.split("\n")) { + lines.push(line.slice(minIndent)); + } + }; + + appendBlock(` + # Paperclip OpenClaw Gateway Onboarding + + This document is meant to be readable by both humans and agents. + + ## Invite + - inviteType: ${invite.inviteType} + - allowedJoinTypes: ${invite.allowedJoinTypes} + - expiresAt: ${invite.expiresAt.toISOString()} + `); + + if (onboarding.inviteMessage) { + appendBlock(` + ## Message from inviter + ${onboarding.inviteMessage} + `); + } + + appendBlock(` + ## Step 0 + + Get the OpenClaw gateway auth token (THIS MUST BE SENT) + Token lives in: + + ~/.openclaw/openclaw.json -> gateway.auth.token + Extract: + + TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')" + test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1) + test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1) + + 3) IMPORTANT: Don't accidentally drop the token when generating JSON + If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var. + + Safe payload build looks sort of like this (substitute where necessary): + + BODY="$(node -e ' + const token = process.argv[1]; + if (!token) process.exit(2); + const body = { + requestType: "agent", + agentName: "OpenClaw", + adapterType: "openclaw_gateway", + capabilities: "OpenClaw agent adapter", + agentDefaultsPayload: { + url: "ws://127.0.0.1:18789", + paperclipApiUrl: "http://host.docker.internal:3100", + headers: { "x-openclaw-token": token }, + waitTimeoutMs: 120000, + sessionKeyStrategy: "issue", + role: "operator", + scopes: ["operator.admin"] + } + }; + process.stdout.write(JSON.stringify(body)); + ' "$TOKEN")" + + ## Step 1: Submit agent join request + ${onboarding.registrationEndpoint.method} ${ + onboarding.registrationEndpoint.url + } + + IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. + Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. + Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL. + Pairing mode requirement: + - Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable. + - You may set disableDeviceAuth=true only for special environments that cannot support pairing. + - First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry. + Do NOT use /v1/responses or /hooks/* in this gateway join flow. + + Body (JSON): + { + "requestType": "agent", + "agentName": "My OpenClaw Agent", + "adapterType": "openclaw_gateway", + "capabilities": "Optional summary", + "agentDefaultsPayload": { + "url": "wss://your-openclaw-gateway.example", + "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", + "headers": { "x-openclaw-token": "replace-me" }, + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] + } + } + + Expected response includes: + - request id + - one-time claimSecret + - claimApiKeyPath + + ## Step 2: Wait for board approval + The board approves the join request in Paperclip before key claim is allowed. + + ## Step 3: Claim API key (one-time) + ${ + onboarding.claimEndpointTemplate.method + } /api/join-requests/{requestId}/claim-api-key + + Body (JSON): + { + "claimSecret": "" + } + + On successful claim, save the full JSON response to: + + - ~/.openclaw/workspace/paperclip-claimed-api-key.json + chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json + + And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here: + https://docs.openclaw.ai/help/environment + + e.g. + + { + env: { + PAPERCLIP_API_KEY: "...", + PAPERCLIP_API_URL: "...", + }, + } + + Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run. + + Important: + - claim secrets expire + - claim secrets are single-use + - claim fails before board approval + + ## Step 4: Install Paperclip skill in OpenClaw + GET ${onboarding.skill.url} + Install path: ${onboarding.skill.installPath} + + Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL + + ## Text onboarding URL + ${onboarding.textInstructions.url} + + ## Connectivity guidance + ${ + onboarding.connectivity?.guidance ?? + "Ensure Paperclip is reachable from your OpenClaw runtime." + } + `); + + const connectionCandidates = Array.isArray( + onboarding.connectivity?.connectionCandidates + ) + ? onboarding.connectivity.connectionCandidates.filter( + (entry): entry is string => Boolean(entry) + ) + : []; + + if (connectionCandidates.length > 0) { + lines.push("## Suggested Paperclip base URLs to try"); + for (const candidate of connectionCandidates) { + lines.push(`- ${candidate}`); + } + appendBlock(` + + Test each candidate with: + - GET /api/health + - set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request + + If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration. + For authenticated/private mode, they may need: + - pnpm paperclipai allowed-hostname + - then restart Paperclip and retry onboarding. + `); + } if (diagnostics.length > 0) { - lines.push("", "## Connectivity diagnostics"); + lines.push("## Connectivity diagnostics"); for (const diag of diagnostics) { lines.push(`- [${diag.level}] ${diag.message}`); if (diag.hint) lines.push(` hint: ${diag.hint}`); } } - lines.push( - "", - "## Helpful endpoints", - `${onboarding.registrationEndpoint.path}`, - `${onboarding.claimEndpointTemplate.path}`, - `${onboarding.skill.path}`, - manifest.invite.onboardingPath, - ); + appendBlock(` + + ## Helpful endpoints + ${onboarding.registrationEndpoint.path} + ${onboarding.claimEndpointTemplate.path} + ${onboarding.skill.path} + ${manifest.invite.onboardingPath} + `); return `${lines.join("\n")}\n`; } +function extractInviteMessage( + invite: typeof invites.$inferSelect +): string | null { + const rawDefaults = invite.defaultsPayload; + if ( + !rawDefaults || + typeof rawDefaults !== "object" || + Array.isArray(rawDefaults) + ) { + return null; + } + const rawMessage = (rawDefaults as Record).agentMessage; + if (typeof rawMessage !== "string") { + return null; + } + const trimmed = rawMessage.trim(); + return trimmed.length ? trimmed : null; +} + +function mergeInviteDefaults( + defaultsPayload: Record | null | undefined, + agentMessage: string | null +): Record | null { + const merged = + defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; + if (agentMessage) { + merged.agentMessage = agentMessage; + } + return Object.keys(merged).length ? merged : null; +} + function requestIp(req: Request) { const forwarded = req.header("x-forwarded-for"); if (forwarded) { @@ -586,8 +1282,11 @@ async function resolveActorEmail(db: Db, req: Request): Promise { function grantsFromDefaults( defaultsPayload: Record | null | undefined, - key: "human" | "agent", -): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record | null }> { + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { if (!defaultsPayload || typeof defaultsPayload !== "object") return []; const scoped = defaultsPayload[key]; if (!scoped || typeof scoped !== "object") return []; @@ -606,14 +1305,138 @@ function grantsFromDefaults( result.push({ permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], scope: - record.scope && typeof record.scope === "object" && !Array.isArray(record.scope) + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) ? (record.scope as Record) - : null, + : null }); } return result; } +type JoinRequestManagerCandidate = { + id: string; + role: string; + reportsTo: string | null; +}; + +export function resolveJoinRequestAgentManagerId( + candidates: JoinRequestManagerCandidate[] +): string | null { + const ceoCandidates = candidates.filter( + (candidate) => candidate.role === "ceo" + ); + if (ceoCandidates.length === 0) return null; + const rootCeo = ceoCandidates.find( + (candidate) => candidate.reportsTo === null + ); + return (rootCeo ?? ceoCandidates[0] ?? null)?.id ?? null; +} + +function isInviteTokenHashCollisionError(error: unknown) { + const candidates = [ + error, + (error as { cause?: unknown } | null)?.cause ?? null + ]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + const code = + "code" in candidate && typeof candidate.code === "string" + ? candidate.code + : null; + const message = + "message" in candidate && typeof candidate.message === "string" + ? candidate.message + : ""; + const constraint = + "constraint" in candidate && typeof candidate.constraint === "string" + ? candidate.constraint + : null; + if (code !== "23505") continue; + if (constraint === "invites_token_hash_unique_idx") return true; + if (message.includes("invites_token_hash_unique_idx")) return true; + } + return false; +} + +function isAbortError(error: unknown) { + return error instanceof Error && error.name === "AbortError"; +} + +type InviteResolutionProbe = { + status: "reachable" | "timeout" | "unreachable"; + method: "HEAD"; + durationMs: number; + httpStatus: number | null; + message: string; +}; + +async function probeInviteResolutionTarget( + url: URL, + timeoutMs: number +): Promise { + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "HEAD", + redirect: "manual", + signal: controller.signal + }); + const durationMs = Date.now() - startedAt; + if ( + response.ok || + response.status === 401 || + response.status === 403 || + response.status === 404 || + response.status === 405 || + response.status === 422 || + response.status === 500 || + response.status === 501 + ) { + return { + status: "reachable", + method: "HEAD", + durationMs, + httpStatus: response.status, + message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.` + }; + } + return { + status: "unreachable", + method: "HEAD", + durationMs, + httpStatus: response.status, + message: `Webhook endpoint probe returned HTTP ${response.status}.` + }; + } catch (error) { + const durationMs = Date.now() - startedAt; + if (isAbortError(error)) { + return { + status: "timeout", + method: "HEAD", + durationMs, + httpStatus: null, + message: `Webhook endpoint probe timed out after ${timeoutMs}ms.` + }; + } + return { + status: "unreachable", + method: "HEAD", + durationMs, + httpStatus: null, + message: + error instanceof Error + ? error.message + : "Webhook endpoint probe failed." + }; + } finally { + clearTimeout(timeout); + } +} + export function accessRoutes( db: Db, opts: { @@ -621,7 +1444,7 @@ export function accessRoutes( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const router = Router(); const access = accessService(db); @@ -636,58 +1459,162 @@ export function accessRoutes( router.get("/board-claim/:token", async (req, res) => { const token = (req.params.token as string).trim(); - const code = typeof req.query.code === "string" ? req.query.code.trim() : undefined; + const code = + typeof req.query.code === "string" ? req.query.code.trim() : undefined; if (!token) throw notFound("Board claim challenge not found"); const challenge = inspectBoardClaimChallenge(token, code); - if (challenge.status === "invalid") throw notFound("Board claim challenge not found"); + if (challenge.status === "invalid") + throw notFound("Board claim challenge not found"); res.json(challenge); }); router.post("/board-claim/:token/claim", async (req, res) => { const token = (req.params.token as string).trim(); - const code = typeof req.body?.code === "string" ? req.body.code.trim() : undefined; + const code = + typeof req.body?.code === "string" ? req.body.code.trim() : undefined; if (!token) throw notFound("Board claim challenge not found"); if (!code) throw badRequest("Claim code is required"); - if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) { + if ( + req.actor.type !== "board" || + req.actor.source !== "session" || + !req.actor.userId + ) { throw unauthorized("Sign in before claiming board ownership"); } const claimed = await claimBoardOwnership(db, { token, code, - userId: req.actor.userId, + userId: req.actor.userId }); - if (claimed.status === "invalid") throw notFound("Board claim challenge not found"); - if (claimed.status === "expired") throw conflict("Board claim challenge expired. Restart server to generate a new one."); + if (claimed.status === "invalid") + throw notFound("Board claim challenge not found"); + if (claimed.status === "expired") + throw conflict( + "Board claim challenge expired. Restart server to generate a new one." + ); if (claimed.status === "claimed") { - res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId }); + res.json({ + claimed: true, + userId: claimed.claimedByUserId ?? req.actor.userId + }); return; } throw conflict("Board claim challenge is no longer available"); }); - async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) { + async function assertCompanyPermission( + req: Request, + companyId: string, + permissionKey: any + ) { assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { if (!req.actor.agentId) throw forbidden(); - const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey); + const allowed = await access.hasPermission( + companyId, + "agent", + req.actor.agentId, + permissionKey + ); if (!allowed) throw forbidden("Permission denied"); return; } if (req.actor.type !== "board") throw unauthorized(); if (isLocalImplicit(req)) return; - const allowed = await access.canUser(companyId, req.actor.userId, permissionKey); + const allowed = await access.canUser( + companyId, + req.actor.userId, + permissionKey + ); if (!allowed) throw forbidden("Permission denied"); } + async function assertCanGenerateOpenClawInvitePrompt( + req: Request, + companyId: string + ) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can generate OpenClaw invite prompts"); + } + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "users:invite"); + if (!allowed) throw forbidden("Permission denied"); + } + + async function createCompanyInviteForCompany(input: { + req: Request; + companyId: string; + allowedJoinTypes: "human" | "agent" | "both"; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }) { + const normalizedAgentMessage = + typeof input.agentMessage === "string" + ? input.agentMessage.trim() || null + : null; + const insertValues = { + companyId: input.companyId, + inviteType: "company_join" as const, + allowedJoinTypes: input.allowedJoinTypes, + defaultsPayload: mergeInviteDefaults( + input.defaultsPayload ?? null, + normalizedAgentMessage + ), + expiresAt: companyInviteExpiresAt(), + invitedByUserId: input.req.actor.userId ?? null + }; + + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + const row = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken) + }) + .returning() + .then((rows) => rows[0]); + token = candidateToken; + created = row; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) { + throw error; + } + } + } + if (!token || !created) { + throw conflict("Failed to generate a unique invite token. Please retry."); + } + + return { token, created, normalizedAgentMessage }; + } + router.get("/skills/index", (_req, res) => { res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, - { name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" }, - ], + { + name: "paperclip-create-agent", + path: "/api/skills/paperclip-create-agent" + } + ] }); }); @@ -704,26 +1631,22 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - - const token = createInviteToken(); - const created = await db - .insert(invites) - .values({ + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, companyId, - inviteType: "company_join", - tokenHash: hashToken(token), allowedJoinTypes: req.body.allowedJoinTypes, defaultsPayload: req.body.defaultsPayload ?? null, - expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), - invitedByUserId: req.actor.userId ?? null, - }) - .returning() - .then((rows) => rows[0]); + agentMessage: req.body.agentMessage ?? null + }); await logActivity(db, { companyId, actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", action: "invite.created", entityType: "invite", entityId: created.id, @@ -731,15 +1654,65 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), - }, + hasAgentMessage: Boolean(normalizedAgentMessage) + } }); + const inviteSummary = toInviteSummaryResponse(req, token, created); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage }); - }, + } + ); + + router.post( + "/companies/:companyId/openclaw/invite-prompt", + validate(createOpenClawInvitePromptSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanGenerateOpenClawInvitePrompt(req, companyId); + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: "agent", + defaultsPayload: null, + agentMessage: req.body.agentMessage ?? null + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", + action: "invite.openclaw_prompt_created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage) + } + }); + + const inviteSummary = toInviteSummaryResponse(req, token, created); + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage + }); + } ); router.get("/invites/:token", async (req, res) => { @@ -750,7 +1723,12 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + if ( + !invite || + invite.revokedAt || + invite.acceptedAt || + inviteExpired(invite) + ) { throw notFound("Invite not found"); } @@ -784,146 +1762,449 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts)); + res + .type("text/plain; charset=utf-8") + .send(buildInviteOnboardingTextDocument(req, token, invite, opts)); }); - router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => { + router.get("/invites/:token/test-resolution", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); - const invite = await db .select() .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) { + if (!invite || invite.revokedAt || inviteExpired(invite)) { throw notFound("Invite not found"); } - if (invite.inviteType === "bootstrap_ceo") { - if (req.body.requestType !== "human") { - throw badRequest("Bootstrap invite requires human request type"); - } - if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) { - throw unauthorized("Authenticated user required for bootstrap acceptance"); - } - const userId = req.actor.userId ?? "local-board"; - const existingAdmin = await access.isInstanceAdmin(userId); - if (!existingAdmin) { - await access.promoteInstanceAdmin(userId); - } - const updatedInvite = await db - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where(eq(invites.id, invite.id)) - .returning() - .then((rows) => rows[0] ?? invite); - res.status(202).json({ - inviteId: updatedInvite.id, - inviteType: updatedInvite.inviteType, - bootstrapAccepted: true, - userId, - }); - return; + const rawUrl = + typeof req.query.url === "string" ? req.query.url.trim() : ""; + if (!rawUrl) throw badRequest("url query parameter is required"); + let target: URL; + try { + target = new URL(rawUrl); + } catch { + throw badRequest("url must be an absolute http(s) URL"); + } + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw badRequest("url must use http or https"); } - const requestType = req.body.requestType as "human" | "agent"; - const companyId = invite.companyId; - if (!companyId) throw conflict("Invite is missing company scope"); - if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) { - throw badRequest(`Invite does not allow ${requestType} joins`); - } - - if (requestType === "human" && req.actor.type !== "board") { - throw unauthorized("Human invite acceptance requires authenticated user"); - } - if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) { - throw unauthorized("Authenticated user is required"); - } - if (requestType === "agent" && !req.body.agentName) { - throw badRequest("agentName is required for agent join requests"); - } - - const joinDefaults = requestType === "agent" - ? normalizeAgentDefaultsForJoin({ - adapterType: req.body.adapterType ?? null, - defaultsPayload: req.body.agentDefaultsPayload ?? null, - deploymentMode: opts.deploymentMode, - deploymentExposure: opts.deploymentExposure, - bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames, - }) - : { normalized: null as Record | null, diagnostics: [] as JoinDiagnostic[] }; - - const claimSecret = requestType === "agent" ? createClaimSecret() : null; - const claimSecretHash = claimSecret ? hashToken(claimSecret) : null; - const claimSecretExpiresAt = claimSecret - ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - : null; - - const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null; - const created = await db.transaction(async (tx) => { - await tx - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt))); - - const row = await tx - .insert(joinRequests) - .values({ - inviteId: invite.id, - companyId, - requestType, - status: "pending_approval", - requestIp: requestIp(req), - requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null, - requestEmailSnapshot: requestType === "human" ? actorEmail : null, - agentName: requestType === "agent" ? req.body.agentName : null, - adapterType: requestType === "agent" ? req.body.adapterType ?? null : null, - capabilities: requestType === "agent" ? req.body.capabilities ?? null : null, - agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, - claimSecretHash, - claimSecretExpiresAt, - }) - .returning() - .then((rows) => rows[0]); - return row; - }); - - await logActivity(db, { - companyId, - actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: - req.actor.type === "agent" - ? req.actor.agentId ?? "invite-agent" - : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), - action: "join.requested", - entityType: "join_request", - entityId: created.id, - details: { requestType, requestIp: created.requestIp }, - }); - - const response = toJoinRequestResponse(created); - if (claimSecret) { - const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts); - res.status(202).json({ - ...response, - claimSecret, - claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`, - onboarding: onboardingManifest.onboarding, - diagnostics: joinDefaults.diagnostics, - }); - return; - } - res.status(202).json({ - ...response, - ...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}), + const parsedTimeoutMs = + typeof req.query.timeoutMs === "string" + ? Number(req.query.timeoutMs) + : NaN; + const timeoutMs = Number.isFinite(parsedTimeoutMs) + ? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs))) + : 5000; + const probe = await probeInviteResolutionTarget(target, timeoutMs); + res.json({ + inviteId: invite.id, + testResolutionPath: `/api/invites/${token}/test-resolution`, + requestedUrl: target.toString(), + timeoutMs, + ...probe }); }); + router.post( + "/invites/:token/accept", + validate(acceptInviteSchema), + async (req, res) => { + const token = (req.params.token as string).trim(); + if (!token) throw notFound("Invite not found"); + + const invite = await db + .select() + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); + } + const inviteAlreadyAccepted = Boolean(invite.acceptedAt); + const existingJoinRequestForInvite = inviteAlreadyAccepted + ? await db + .select() + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null) + : null; + + if (invite.inviteType === "bootstrap_ceo") { + if (inviteAlreadyAccepted) throw notFound("Invite not found"); + if (req.body.requestType !== "human") { + throw badRequest("Bootstrap invite requires human request type"); + } + if ( + req.actor.type !== "board" || + (!req.actor.userId && !isLocalImplicit(req)) + ) { + throw unauthorized( + "Authenticated user required for bootstrap acceptance" + ); + } + const userId = req.actor.userId ?? "local-board"; + const existingAdmin = await access.isInstanceAdmin(userId); + if (!existingAdmin) { + await access.promoteInstanceAdmin(userId); + } + const updatedInvite = await db + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where(eq(invites.id, invite.id)) + .returning() + .then((rows) => rows[0] ?? invite); + res.status(202).json({ + inviteId: updatedInvite.id, + inviteType: updatedInvite.inviteType, + bootstrapAccepted: true, + userId + }); + return; + } + + const requestType = req.body.requestType as "human" | "agent"; + const companyId = invite.companyId; + if (!companyId) throw conflict("Invite is missing company scope"); + if ( + invite.allowedJoinTypes !== "both" && + invite.allowedJoinTypes !== requestType + ) { + throw badRequest(`Invite does not allow ${requestType} joins`); + } + + if (requestType === "human" && req.actor.type !== "board") { + throw unauthorized( + "Human invite acceptance requires authenticated user" + ); + } + if ( + requestType === "human" && + !req.actor.userId && + !isLocalImplicit(req) + ) { + throw unauthorized("Authenticated user is required"); + } + if (requestType === "agent" && !req.body.agentName) { + if ( + !inviteAlreadyAccepted || + !existingJoinRequestForInvite?.agentName + ) { + throw badRequest("agentName is required for agent join requests"); + } + } + + const adapterType = req.body.adapterType ?? null; + if ( + inviteAlreadyAccepted && + !canReplayOpenClawGatewayInviteAccept({ + requestType, + adapterType, + existingJoinRequest: existingJoinRequestForInvite + }) + ) { + throw notFound("Invite not found"); + } + const replayJoinRequestId = inviteAlreadyAccepted + ? existingJoinRequestForInvite?.id ?? null + : null; + if (inviteAlreadyAccepted && !replayJoinRequestId) { + throw conflict("Join request not found"); + } + + const replayMergedDefaults = inviteAlreadyAccepted + ? mergeJoinDefaultsPayloadForReplay( + existingJoinRequestForInvite?.agentDefaultsPayload ?? null, + req.body.agentDefaultsPayload ?? null + ) + : req.body.agentDefaultsPayload ?? null; + + const gatewayDefaultsPayload = + requestType === "agent" + ? buildJoinDefaultsPayloadForAccept({ + adapterType, + defaultsPayload: replayMergedDefaults, + paperclipApiUrl: req.body.paperclipApiUrl ?? null, + inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, + inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null + }) + : null; + + const joinDefaults = + requestType === "agent" + ? normalizeAgentDefaultsForJoin({ + adapterType, + defaultsPayload: gatewayDefaultsPayload, + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames + }) + : { + normalized: null as Record | null, + diagnostics: [] as JoinDiagnostic[], + fatalErrors: [] as string[] + }; + + if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) { + throw badRequest(joinDefaults.fatalErrors.join("; ")); + } + + if (requestType === "agent" && adapterType === "openclaw_gateway") { + logger.info( + { + inviteId: invite.id, + joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level + })), + normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog( + joinDefaults.normalized + ) + }, + "invite accept normalized OpenClaw gateway defaults" + ); + } + + const claimSecret = + requestType === "agent" && !inviteAlreadyAccepted + ? createClaimSecret() + : null; + const claimSecretHash = claimSecret ? hashToken(claimSecret) : null; + const claimSecretExpiresAt = claimSecret + ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + : null; + + const actorEmail = + requestType === "human" ? await resolveActorEmail(db, req) : null; + const created = !inviteAlreadyAccepted + ? await db.transaction(async (tx) => { + await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ); + + const row = await tx + .insert(joinRequests) + .values({ + inviteId: invite.id, + companyId, + requestType, + status: "pending_approval", + requestIp: requestIp(req), + requestingUserId: + requestType === "human" + ? req.actor.userId ?? "local-board" + : null, + requestEmailSnapshot: + requestType === "human" ? actorEmail : null, + agentName: requestType === "agent" ? req.body.agentName : null, + adapterType: requestType === "agent" ? adapterType : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? null + : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + claimSecretHash, + claimSecretExpiresAt + }) + .returning() + .then((rows) => rows[0]); + return row; + }) + : await db + .update(joinRequests) + .set({ + requestIp: requestIp(req), + agentName: + requestType === "agent" + ? req.body.agentName ?? + existingJoinRequestForInvite?.agentName ?? + null + : null, + capabilities: + requestType === "agent" + ? req.body.capabilities ?? + existingJoinRequestForInvite?.capabilities ?? + null + : null, + adapterType: requestType === "agent" ? adapterType : null, + agentDefaultsPayload: + requestType === "agent" ? joinDefaults.normalized : null, + updatedAt: new Date() + }) + .where(eq(joinRequests.id, replayJoinRequestId as string)) + .returning() + .then((rows) => rows[0]); + + if (!created) { + throw conflict("Join request not found"); + } + + if ( + inviteAlreadyAccepted && + requestType === "agent" && + adapterType === "openclaw_gateway" && + created.status === "approved" && + created.createdAgentId + ) { + const existingAgent = await agents.getById(created.createdAgentId); + if (!existingAgent) { + throw conflict("Approved join request agent not found"); + } + const existingAdapterConfig = isPlainObject(existingAgent.adapterConfig) + ? (existingAgent.adapterConfig as Record) + : {}; + const nextAdapterConfig = { + ...existingAdapterConfig, + ...(joinDefaults.normalized ?? {}) + }; + const updatedAgent = await agents.update(created.createdAgentId, { + adapterType, + adapterConfig: nextAdapterConfig + }); + if (!updatedAgent) { + throw conflict("Approved join request agent not found"); + } + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "invite-agent" + : req.actor.userId ?? "board", + action: "agent.updated_from_join_replay", + entityType: "agent", + entityId: updatedAgent.id, + details: { inviteId: invite.id, joinRequestId: created.id } + }); + } + + if (requestType === "agent" && adapterType === "openclaw_gateway") { + const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog( + joinDefaults.normalized + ); + const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog( + created.agentDefaultsPayload + ); + const missingPersistedFields: string[] = []; + + if (expectedDefaults.url && !persistedDefaults.url) + missingPersistedFields.push("url"); + if ( + expectedDefaults.paperclipApiUrl && + !persistedDefaults.paperclipApiUrl + ) { + missingPersistedFields.push("paperclipApiUrl"); + } + if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) { + missingPersistedFields.push("headers.x-openclaw-token"); + } + if ( + expectedDefaults.devicePrivateKeyPem && + !persistedDefaults.devicePrivateKeyPem + ) { + missingPersistedFields.push("devicePrivateKeyPem"); + } + if ( + expectedDefaults.headerKeys.length > 0 && + persistedDefaults.headerKeys.length === 0 + ) { + missingPersistedFields.push("headers"); + } + + logger.info( + { + inviteId: invite.id, + joinRequestId: created.id, + joinRequestStatus: created.status, + expectedDefaults, + persistedDefaults, + diagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level, + message: diag.message, + hint: diag.hint ?? null + })) + }, + "invite accept persisted OpenClaw gateway join request" + ); + + if (missingPersistedFields.length > 0) { + logger.warn( + { + inviteId: invite.id, + joinRequestId: created.id, + missingPersistedFields + }, + "invite accept detected missing persisted OpenClaw gateway defaults" + ); + } + } + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "invite-agent" + : req.actor.userId ?? + (requestType === "agent" ? "invite-anon" : "board"), + action: inviteAlreadyAccepted + ? "join.request_replayed" + : "join.requested", + entityType: "join_request", + entityId: created.id, + details: { + requestType, + requestIp: created.requestIp, + inviteReplay: inviteAlreadyAccepted + } + }); + + const response = toJoinRequestResponse(created); + if (claimSecret) { + const onboardingManifest = buildInviteOnboardingManifest( + req, + token, + invite, + opts + ); + res.status(202).json({ + ...response, + claimSecret, + claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`, + onboarding: onboardingManifest.onboarding, + diagnostics: joinDefaults.diagnostics + }); + return; + } + res.status(202).json({ + ...response, + ...(joinDefaults.diagnostics.length > 0 + ? { diagnostics: joinDefaults.diagnostics } + : {}) + }); + } + ); + router.post("/invites/:inviteId/revoke", async (req, res) => { const id = req.params.inviteId as string; - const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null); + const invite = await db + .select() + .from(invites) + .where(eq(invites.id, id)) + .then((rows) => rows[0] ?? null); if (!invite) throw notFound("Invite not found"); if (invite.inviteType === "bootstrap_ceo") { await assertInstanceAdmin(req); @@ -945,10 +2226,13 @@ export function accessRoutes( await logActivity(db, { companyId: invite.companyId, actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", action: "invite.revoked", entityType: "invite", - entityId: id, + entityId: id }); } @@ -966,189 +2250,288 @@ export function accessRoutes( .orderBy(desc(joinRequests.createdAt)); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; - if (query.requestType && row.requestType !== query.requestType) return false; + if (query.requestType && row.requestType !== query.requestType) + return false; return true; }); res.json(filtered.map(toJoinRequestResponse)); }); - router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => { - const companyId = req.params.companyId as string; - const requestId = req.params.requestId as string; - await assertCompanyPermission(req, companyId, "joins:approve"); + router.post( + "/companies/:companyId/join-requests/:requestId/approve", + async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); - const existing = await db - .select() - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) - .then((rows) => rows[0] ?? null); - if (!existing) throw notFound("Join request not found"); - if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); + const existing = await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.id, requestId) + ) + ) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") + throw conflict("Join request is not pending"); - const invite = await db - .select() - .from(invites) - .where(eq(invites.id, existing.inviteId)) - .then((rows) => rows[0] ?? null); - if (!invite) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.id, existing.inviteId)) + .then((rows) => rows[0] ?? null); + if (!invite) throw notFound("Invite not found"); - let createdAgentId: string | null = existing.createdAgentId ?? null; - if (existing.requestType === "human") { - if (!existing.requestingUserId) throw conflict("Join request missing user identity"); - await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active"); - const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "human"); - await access.setPrincipalGrants( + let createdAgentId: string | null = existing.createdAgentId ?? null; + if (existing.requestType === "human") { + if (!existing.requestingUserId) + throw conflict("Join request missing user identity"); + await access.ensureMembership( + companyId, + "user", + existing.requestingUserId, + "member", + "active" + ); + const grants = grantsFromDefaults( + invite.defaultsPayload as Record | null, + "human" + ); + await access.setPrincipalGrants( + companyId, + "user", + existing.requestingUserId, + grants, + req.actor.userId ?? null + ); + } else { + const existingAgents = await agents.list(companyId); + const managerId = resolveJoinRequestAgentManagerId(existingAgents); + if (!managerId) { + throw conflict( + "Join request cannot be approved because this company has no active CEO" + ); + } + + const agentName = deduplicateAgentName( + existing.agentName ?? "New Agent", + existingAgents.map((a) => ({ + id: a.id, + name: a.name, + status: a.status + })) + ); + + const created = await agents.create(companyId, { + name: agentName, + role: "general", + title: null, + status: "idle", + reportsTo: managerId, + capabilities: existing.capabilities ?? null, + adapterType: existing.adapterType ?? "process", + adapterConfig: + existing.agentDefaultsPayload && + typeof existing.agentDefaultsPayload === "object" + ? (existing.agentDefaultsPayload as Record) + : {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null + }); + createdAgentId = created.id; + await access.ensureMembership( + companyId, + "agent", + created.id, + "member", + "active" + ); + const grants = grantsFromDefaults( + invite.defaultsPayload as Record | null, + "agent" + ); + await access.setPrincipalGrants( + companyId, + "agent", + created.id, + grants, + req.actor.userId ?? null + ); + } + + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId: + req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + approvedAt: new Date(), + createdAgentId, + updatedAt: new Date() + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); + + await logActivity(db, { companyId, - "user", - existing.requestingUserId, - grants, - req.actor.userId ?? null, - ); - } else { - const created = await agents.create(companyId, { - name: existing.agentName ?? "New Agent", - role: "general", - title: null, - status: "idle", - reportsTo: null, - capabilities: existing.capabilities ?? null, - adapterType: existing.adapterType ?? "process", - adapterConfig: - existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object" - ? (existing.agentDefaultsPayload as Record) - : {}, - runtimeConfig: {}, - budgetMonthlyCents: 0, - spentMonthlyCents: 0, - permissions: {}, - lastHeartbeatAt: null, - metadata: null, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType, createdAgentId } }); - createdAgentId = created.id; - await access.ensureMembership(companyId, "agent", created.id, "member", "active"); - const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "agent"); - await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null); + + if (createdAgentId) { + void notifyHireApproved(db, { + companyId, + agentId: createdAgentId, + source: "join_request", + sourceId: requestId, + approvedAt: new Date() + }).catch(() => {}); + } + + res.json(toJoinRequestResponse(approved)); } + ); - const approved = await db - .update(joinRequests) - .set({ - status: "approved", - approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), - approvedAt: new Date(), - createdAgentId, - updatedAt: new Date(), - }) - .where(eq(joinRequests.id, requestId)) - .returning() - .then((rows) => rows[0]); + router.post( + "/companies/:companyId/join-requests/:requestId/reject", + async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "join.approved", - entityType: "join_request", - entityId: requestId, - details: { requestType: existing.requestType, createdAgentId }, - }); + const existing = await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.id, requestId) + ) + ) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") + throw conflict("Join request is not pending"); - res.json(toJoinRequestResponse(approved)); - }); + const rejected = await db + .update(joinRequests) + .set({ + status: "rejected", + rejectedByUserId: + req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + rejectedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); - router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => { - const companyId = req.params.companyId as string; - const requestId = req.params.requestId as string; - await assertCompanyPermission(req, companyId, "joins:approve"); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.rejected", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType } + }); - const existing = await db - .select() - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) - .then((rows) => rows[0] ?? null); - if (!existing) throw notFound("Join request not found"); - if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); - - const rejected = await db - .update(joinRequests) - .set({ - status: "rejected", - rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), - rejectedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(joinRequests.id, requestId)) - .returning() - .then((rows) => rows[0]); - - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "join.rejected", - entityType: "join_request", - entityId: requestId, - details: { requestType: existing.requestType }, - }); - - res.json(toJoinRequestResponse(rejected)); - }); - - router.post("/join-requests/:requestId/claim-api-key", validate(claimJoinRequestApiKeySchema), async (req, res) => { - const requestId = req.params.requestId as string; - const presentedClaimSecretHash = hashToken(req.body.claimSecret); - const joinRequest = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.id, requestId)) - .then((rows) => rows[0] ?? null); - if (!joinRequest) throw notFound("Join request not found"); - if (joinRequest.requestType !== "agent") throw badRequest("Only agent join requests can claim API keys"); - if (joinRequest.status !== "approved") throw conflict("Join request must be approved before key claim"); - if (!joinRequest.createdAgentId) throw conflict("Join request has no created agent"); - if (!joinRequest.claimSecretHash) throw conflict("Join request is missing claim secret metadata"); - if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) { - throw forbidden("Invalid claim secret"); + res.json(toJoinRequestResponse(rejected)); } - if (joinRequest.claimSecretExpiresAt && joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) { - throw conflict("Claim secret expired"); + ); + + router.post( + "/join-requests/:requestId/claim-api-key", + validate(claimJoinRequestApiKeySchema), + async (req, res) => { + const requestId = req.params.requestId as string; + const presentedClaimSecretHash = hashToken(req.body.claimSecret); + const joinRequest = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.id, requestId)) + .then((rows) => rows[0] ?? null); + if (!joinRequest) throw notFound("Join request not found"); + if (joinRequest.requestType !== "agent") + throw badRequest("Only agent join requests can claim API keys"); + if (joinRequest.status !== "approved") + throw conflict("Join request must be approved before key claim"); + if (!joinRequest.createdAgentId) + throw conflict("Join request has no created agent"); + if (!joinRequest.claimSecretHash) + throw conflict("Join request is missing claim secret metadata"); + if ( + !tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash) + ) { + throw forbidden("Invalid claim secret"); + } + if ( + joinRequest.claimSecretExpiresAt && + joinRequest.claimSecretExpiresAt.getTime() <= Date.now() + ) { + throw conflict("Claim secret expired"); + } + if (joinRequest.claimSecretConsumedAt) + throw conflict("Claim secret already used"); + + const existingKey = await db + .select({ id: agentApiKeys.id }) + .from(agentApiKeys) + .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId)) + .then((rows) => rows[0] ?? null); + if (existingKey) throw conflict("API key already claimed"); + + const consumed = await db + .update(joinRequests) + .set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(joinRequests.id, requestId), + isNull(joinRequests.claimSecretConsumedAt) + ) + ) + .returning({ id: joinRequests.id }) + .then((rows) => rows[0] ?? null); + if (!consumed) throw conflict("Claim secret already used"); + + const created = await agents.createApiKey( + joinRequest.createdAgentId, + "initial-join-key" + ); + + await logActivity(db, { + companyId: joinRequest.companyId, + actorType: "system", + actorId: "join-claim", + action: "agent_api_key.claimed", + entityType: "agent_api_key", + entityId: created.id, + details: { + agentId: joinRequest.createdAgentId, + joinRequestId: requestId + } + }); + + res.status(201).json({ + keyId: created.id, + token: created.token, + agentId: joinRequest.createdAgentId, + createdAt: created.createdAt + }); } - if (joinRequest.claimSecretConsumedAt) throw conflict("Claim secret already used"); - - const existingKey = await db - .select({ id: agentApiKeys.id }) - .from(agentApiKeys) - .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId)) - .then((rows) => rows[0] ?? null); - if (existingKey) throw conflict("API key already claimed"); - - const consumed = await db - .update(joinRequests) - .set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() }) - .where(and(eq(joinRequests.id, requestId), isNull(joinRequests.claimSecretConsumedAt))) - .returning({ id: joinRequests.id }) - .then((rows) => rows[0] ?? null); - if (!consumed) throw conflict("Claim secret already used"); - - const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key"); - - await logActivity(db, { - companyId: joinRequest.companyId, - actorType: "system", - actorId: "join-claim", - action: "agent_api_key.claimed", - entityType: "agent_api_key", - entityId: created.id, - details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId }, - }); - - res.status(201).json({ - keyId: created.id, - token: created.token, - agentId: joinRequest.createdAgentId, - createdAt: created.createdAt, - }); - }); + ); router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; @@ -1168,27 +2551,33 @@ export function accessRoutes( companyId, memberId, req.body.grants ?? [], - req.actor.userId ?? null, + req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); res.json(updated); - }, + } ); - router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => { - await assertInstanceAdmin(req); - const userId = req.params.userId as string; - const result = await access.promoteInstanceAdmin(userId); - res.status(201).json(result); - }); + router.post( + "/admin/users/:userId/promote-instance-admin", + async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const result = await access.promoteInstanceAdmin(userId); + res.status(201).json(result); + } + ); - router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => { - await assertInstanceAdmin(req); - const userId = req.params.userId as string; - const removed = await access.demoteInstanceAdmin(userId); - if (!removed) throw notFound("Instance admin role not found"); - res.json(removed); - }); + router.post( + "/admin/users/:userId/demote-instance-admin", + async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const removed = await access.demoteInstanceAdmin(userId); + if (!removed) throw notFound("Instance admin role not found"); + res.json(removed); + } + ); router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); @@ -1203,9 +2592,12 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []); + const memberships = await access.setUserCompanyAccess( + userId, + req.body.companyIds ?? [] + ); res.json(memberships); - }, + } ); return router; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 607c451a..d150bb10 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; @@ -27,7 +27,7 @@ import { logActivity, secretService, } from "../services/index.js"; -import { conflict, forbidden, unprocessable } from "../errors.js"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; @@ -37,7 +37,7 @@ import { DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { @@ -152,7 +152,10 @@ export function agentRoutes(db: Db) { if (resolved.ambiguous) { throw conflict("Agent shortname is ambiguous in this company. Use the agent ID."); } - return resolved.agent?.id ?? raw; + if (!resolved.agent) { + throw notFound("Agent not found"); + } + return resolved.agent.id; } function parseSourceIssueIds(input: { @@ -178,6 +181,40 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return null; + } + + function generateEd25519PrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + } + + function ensureGatewayDeviceKey( + adapterType: string | null | undefined, + adapterConfig: Record, + ): Record { + if (adapterType !== "openclaw_gateway") return adapterConfig; + const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; + if (disableDeviceAuth) return adapterConfig; + if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; + return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; + } + function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, @@ -193,15 +230,34 @@ export function agentRoutes(db: Db) { if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } - return next; - } - if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { - next.model = DEFAULT_OPENCODE_LOCAL_MODEL; + return ensureGatewayDeviceKey(adapterType, next); } + // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } - return next; + return ensureGatewayDeviceKey(adapterType, next); + } + + async function assertAdapterConfigConstraints( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } } function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record) { @@ -335,7 +391,9 @@ export function agentRoutes(db: Db) { } }); - router.get("/adapters/:type/models", async (req, res) => { + router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const type = req.params.type as string; const models = await listAdapterModels(type); res.json(models); @@ -362,7 +420,7 @@ export function agentRoutes(db: Db) { inputAdapterConfig, { strictMode: strictSecretsMode }, ); - const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime( + const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, ); @@ -589,6 +647,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + hireInput.adapterType, + normalizedAdapterConfig, + ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, @@ -724,6 +787,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + req.body.adapterType, + normalizedAdapterConfig, + ); const agent = await svc.create(companyId, { ...req.body, @@ -896,11 +964,36 @@ export function agentRoutes(db: Db) { if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } - patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + patchData.adapterConfig = adapterConfig; + } + + const requestedAdapterType = + typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType; + const touchesAdapterConfiguration = + Object.prototype.hasOwnProperty.call(patchData, "adapterType") || + Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); + if (touchesAdapterConfiguration) { + const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + ? (asRecord(patchData.adapterConfig) ?? {}) + : (asRecord(existing.adapterConfig) ?? {}); + const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( + requestedAdapterType, + rawEffectiveAdapterConfig, + ); + const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, - adapterConfig, + effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + patchData.adapterConfig = normalizedEffectiveAdapterConfig; + } + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; + await assertAdapterConfigConstraints( + existing.companyId, + requestedAdapterType, + effectiveAdapterConfig, + ); } const actor = getActorInfo(req); @@ -1171,7 +1264,7 @@ export function agentRoutes(db: Db) { } const config = asRecord(agent.adapterConfig) ?? {}; - const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); const result = await runClaudeLogin({ runId: `claude-login-${randomUUID()}`, agent: { diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index e0505fbb..f034e402 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -43,6 +43,13 @@ export function companyRoutes(db: Db) { res.json(filtered); }); + // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues". + router.get("/issues", (_req, res) => { + res.status(400).json({ + error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", + }); + }); + router.get("/:companyId", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/routes/issues-checkout-wakeup.ts b/server/src/routes/issues-checkout-wakeup.ts new file mode 100644 index 00000000..287b7bbf --- /dev/null +++ b/server/src/routes/issues-checkout-wakeup.ts @@ -0,0 +1,14 @@ +type CheckoutWakeInput = { + actorType: "board" | "agent" | "none"; + actorAgentId: string | null; + checkoutAgentId: string; + checkoutRunId: string | null; +}; + +export function shouldWakeAssigneeOnCheckout(input: CheckoutWakeInput): boolean { + if (input.actorType !== "agent") return true; + if (!input.actorAgentId) return true; + if (input.actorAgentId !== input.checkoutAgentId) return true; + if (!input.checkoutRunId) return true; + return false; +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 824493f4..e4035dfc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -25,6 +25,7 @@ import { import { logger } from "../middleware/logger.js"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([ @@ -183,24 +184,51 @@ export function issueRoutes(db: Db, storage: StorageService) { } }); + // Common malformed path when companyId is empty in "/api/companies/{companyId}/issues". + router.get("/issues", (_req, res) => { + res.status(400).json({ + error: "Missing companyId in path. Use /api/companies/{companyId}/issues.", + }); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; + const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined; + const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined; const assigneeUserId = assigneeUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : assigneeUserFilterRaw; + const touchedByUserId = + touchedByUserFilterRaw === "me" && req.actor.type === "board" + ? req.actor.userId + : touchedByUserFilterRaw; + const unreadForUserId = + unreadForUserFilterRaw === "me" && req.actor.type === "board" + ? req.actor.userId + : unreadForUserFilterRaw; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); return; } + if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) { + res.status(403).json({ error: "touchedByUserId=me requires board authentication" }); + return; + } + if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) { + res.status(403).json({ error: "unreadForUserId=me requires board authentication" }); + return; + } const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, assigneeUserId, + touchedByUserId, + unreadForUserId, projectId: req.query.projectId as string | undefined, labelId: req.query.labelId as string | undefined, q: req.query.q as string | undefined, @@ -282,6 +310,38 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects }); }); + router.post("/issues/:id/read", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date()); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.read_marked", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt }, + }); + res.json(readState); + }); + router.get("/issues/:id/approvals", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -379,7 +439,7 @@ export function issueRoutes(db: Db, storage: StorageService) { details: { title: issue.title, identifier: issue.identifier }, }); - if (issue.assigneeAgentId) { + if (issue.assigneeAgentId && issue.status !== "backlog") { void heartbeat .wakeup(issue.assigneeAgentId, { source: "assignment", @@ -469,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) { } const actor = getActorInfo(req); + const hasFieldChanges = Object.keys(previous).length > 0; await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, @@ -478,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) { action: "issue.updated", entityType: "issue", entityId: issue.id, - details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined }, + details: { + ...updateFields, + identifier: issue.identifier, + ...(commentBody ? { source: "comment" } : {}), + _previous: hasFieldChanges ? previous : undefined, + }, }); let comment = null; @@ -502,18 +568,23 @@ export function issueRoutes(db: Db, storage: StorageService) { bodySnippet: comment.body.slice(0, 120), identifier: issue.identifier, issueTitle: issue.title, + ...(hasFieldChanges ? { updated: true } : {}), }, }); } const assigneeChanged = assigneeWillChange; + const statusChangedFromBacklog = + existing.status === "backlog" && + issue.status !== "backlog" && + req.body.status !== undefined; // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs. void (async () => { const wakeups = new Map[1]>(); - if (assigneeChanged && issue.assigneeAgentId) { + if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") { wakeups.set(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", @@ -525,6 +596,18 @@ export function issueRoutes(db: Db, storage: StorageService) { }); } + if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) { + wakeups.set(issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_status_changed", + payload: { issueId: issue.id, mutation: "update" }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + }); + } + if (commentBody && comment) { let mentionedIds: string[] = []; try { @@ -535,6 +618,7 @@ export function issueRoutes(db: Db, storage: StorageService) { for (const mentionedId of mentionedIds) { if (wakeups.has(mentionedId)) continue; + if (actor.actorType === "agent" && actor.actorId === mentionedId) continue; wakeups.set(mentionedId, { source: "automation", triggerDetail: "system", @@ -634,17 +718,26 @@ export function issueRoutes(db: Db, storage: StorageService) { details: { agentId: req.body.agentId }, }); - void heartbeat - .wakeup(req.body.agentId, { - source: "assignment", - triggerDetail: "system", - reason: "issue_checked_out", - payload: { issueId: issue.id, mutation: "checkout" }, - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.checkout" }, + if ( + shouldWakeAssigneeOnCheckout({ + actorType: req.actor.type, + actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null, + checkoutAgentId: req.body.agentId, + checkoutRunId, }) - .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout")); + ) { + void heartbeat + .wakeup(req.body.agentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_checked_out", + payload: { issueId: issue.id, mutation: "checkout" }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId: issue.id, source: "issue.checkout" }, + }) + .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout")); + } res.json(updated); }); @@ -837,7 +930,10 @@ export function issueRoutes(db: Db, storage: StorageService) { void (async () => { const wakeups = new Map[1]>(); const assigneeId = currentIssue.assigneeAgentId; - if (assigneeId) { + const actorIsAgent = actor.actorType === "agent"; + const selfComment = actorIsAgent && actor.actorId === assigneeId; + const skipWake = selfComment || isClosed; + if (assigneeId && (reopened || !skipWake)) { if (reopened) { wakeups.set(assigneeId, { source: "automation", @@ -896,6 +992,7 @@ export function issueRoutes(db: Db, storage: StorageService) { for (const mentionedId of mentionedIds) { if (wakeups.has(mentionedId)) continue; + if (actorIsAgent && actor.actorId === mentionedId) continue; wakeups.set(mentionedId, { source: "automation", triggerDetail: "system", diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index f908e86e..0cd302e5 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -1,17 +1,19 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; -import { issues, joinRequests } from "@paperclipai/db"; +import { and, eq, sql } from "drizzle-orm"; +import { joinRequests } from "@paperclipai/db"; import { sidebarBadgeService } from "../services/sidebar-badges.js"; +import { issueService } from "../services/issues.js"; import { accessService } from "../services/access.js"; +import { dashboardService } from "../services/dashboard.js"; import { assertCompanyAccess } from "./authz.js"; -const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const; - export function sidebarBadgeRoutes(db: Db) { const router = Router(); const svc = sidebarBadgeService(db); + const issueSvc = issueService(db); const access = accessService(db); + const dashboard = dashboardService(db); router.get("/companies/:companyId/sidebar-badges", async (req, res) => { const companyId = req.params.companyId as string; @@ -34,26 +36,17 @@ export function sidebarBadgeRoutes(db: Db) { .then((rows) => Number(rows[0]?.count ?? 0)) : 0; - const assignedIssueCount = - req.actor.type === "board" && req.actor.userId - ? await db - .select({ count: sql`count(*)` }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.assigneeUserId, req.actor.userId), - inArray(issues.status, [...INBOX_ISSUE_STATUSES]), - isNull(issues.hiddenAt), - ), - ) - .then((rows) => Number(rows[0]?.count ?? 0)) - : 0; - const badges = await svc.get(companyId, { joinRequests: joinRequestCount, - assignedIssues: assignedIssueCount, }); + const summary = await dashboard.summary(companyId); + const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60); + const hasFailedRuns = badges.failedRuns > 0; + const alertsCount = + (summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) + + (summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0); + badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals; + res.json(badges); }); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 703a3f95..fa65c7e4 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -51,6 +51,16 @@ interface UpdateAgentOptions { recordRevision?: RevisionMetadata; } +interface AgentShortnameRow { + id: string; + name: string; + status: string; +} + +interface AgentShortnameCollisionOptions { + excludeAgentId?: string | null; +} + function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -140,6 +150,37 @@ function configPatchFromSnapshot(snapshot: unknown): Partial { + if (agent.status === "terminated") return false; + if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false; + return normalizeAgentUrlKey(agent.name) === candidateShortname; + }); +} + +export function deduplicateAgentName( + candidateName: string, + existingAgents: AgentShortnameRow[], +): string { + if (!hasAgentShortnameCollision(candidateName, existingAgents)) { + return candidateName; + } + for (let i = 2; i <= 100; i++) { + const suffixed = `${candidateName} ${i}`; + if (!hasAgentShortnameCollision(suffixed, existingAgents)) { + return suffixed; + } + } + return `${candidateName} ${Date.now()}`; +} + export function agentService(db: Db) { function withUrlKey(row: T) { return { @@ -185,6 +226,31 @@ export function agentService(db: Db) { } } + async function assertCompanyShortnameAvailable( + companyId: string, + candidateName: string, + options?: AgentShortnameCollisionOptions, + ) { + const candidateShortname = normalizeAgentUrlKey(candidateName); + if (!candidateShortname) return; + + const existingAgents = await db + .select({ + id: agents.id, + name: agents.name, + status: agents.status, + }) + .from(agents) + .where(eq(agents.companyId, companyId)); + + const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options); + if (hasCollision) { + throw conflict( + `Agent shortname '${candidateShortname}' is already in use in this company`, + ); + } + } + async function updateAgent( id: string, data: Partial, @@ -212,6 +278,14 @@ export function agentService(db: Db) { await assertNoCycle(id, data.reportsTo); } + if (data.name !== undefined) { + const previousShortname = normalizeAgentUrlKey(existing.name); + const nextShortname = normalizeAgentUrlKey(data.name); + if (previousShortname !== nextShortname) { + await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id }); + } + } + const normalizedPatch = { ...data } as Partial; if (data.permissions !== undefined) { const role = (data.role ?? existing.role) as string; @@ -267,11 +341,17 @@ export function agentService(db: Db) { await ensureManager(companyId, data.reportsTo); } + const existingAgents = await db + .select({ id: agents.id, name: agents.name, status: agents.status }) + .from(agents) + .where(eq(agents.companyId, companyId)); + const uniqueName = deduplicateAgentName(data.name, existingAgents); + const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); const created = await db .insert(agents) - .values({ ...data, companyId, role, permissions: normalizedPermissions }) + .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) .returning() .then((rows) => rows[0]); diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 39810cdc..ba2890a3 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; +import { notifyHireApproved } from "./hire-hook.js"; export function approvalService(db: Db) { const agentsSvc = agentService(db); @@ -59,13 +60,15 @@ export function approvalService(db: Db) { .returning() .then((rows) => rows[0]); + let hireApprovedAgentId: string | null = null; if (updated.type === "hire_agent") { const payload = updated.payload as Record; const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; if (payloadAgentId) { await agentsSvc.activatePendingApproval(payloadAgentId); + hireApprovedAgentId = payloadAgentId; } else { - await agentsSvc.create(updated.companyId, { + const created = await agentsSvc.create(updated.companyId, { name: String(payload.name ?? "New Agent"), role: String(payload.role ?? "general"), title: typeof payload.title === "string" ? payload.title : null, @@ -87,6 +90,16 @@ export function approvalService(db: Db) { permissions: undefined, lastHeartbeatAt: null, }); + hireApprovedAgentId = created?.id ?? null; + } + if (hireApprovedAgentId) { + void notifyHireApproved(db, { + companyId: updated.companyId, + agentId: hireApprovedAgentId, + source: "approval", + sourceId: id, + approvedAt: now, + }).catch(() => {}); } } diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cc59063c..ac6de363 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -83,9 +83,13 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { + if (meta.env && secretKeys.size > 0) { + for (const key of secretKeys) { + if (key in meta.env) meta.env[key] = "***REDACTED***"; + } + } await appendRunEvent(currentRun, seq++, { eventType: "adapter.invoke", stream: "system", diff --git a/server/src/services/hire-hook.ts b/server/src/services/hire-hook.ts new file mode 100644 index 00000000..6b6e22ce --- /dev/null +++ b/server/src/services/hire-hook.ts @@ -0,0 +1,113 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import type { HireApprovedPayload } from "@paperclipai/adapter-utils"; +import { findServerAdapter } from "../adapters/registry.js"; +import { logger } from "../middleware/logger.js"; +import { logActivity } from "./activity-log.js"; + +const HIRE_APPROVED_MESSAGE = + "Tell your user that your hire was approved, now they should assign you a task in Paperclip or ask you to create issues."; + +export interface NotifyHireApprovedInput { + companyId: string; + agentId: string; + source: "join_request" | "approval"; + sourceId: string; + approvedAt?: Date; +} + +/** + * Invokes the adapter's onHireApproved hook when an agent is approved (join-request or hire_agent approval). + * Failures are non-fatal: we log and write to activity, never throw. + */ +export async function notifyHireApproved( + db: Db, + input: NotifyHireApprovedInput, +): Promise { + const { companyId, agentId, source, sourceId } = input; + const approvedAt = input.approvedAt ?? new Date(); + + const row = await db + .select() + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))) + .then((rows) => rows[0] ?? null); + + if (!row) { + logger.warn({ companyId, agentId, source, sourceId }, "hire hook: agent not found in company, skipping"); + return; + } + + const adapterType = row.adapterType ?? "process"; + const adapter = findServerAdapter(adapterType); + const onHireApproved = adapter?.onHireApproved; + if (!onHireApproved) { + return; + } + + const payload: HireApprovedPayload = { + companyId, + agentId, + agentName: row.name, + adapterType, + source, + sourceId, + approvedAt: approvedAt.toISOString(), + message: HIRE_APPROVED_MESSAGE, + }; + + const adapterConfig = + typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig) + ? (row.adapterConfig as Record) + : {}; + + try { + const result = await onHireApproved(payload, adapterConfig); + if (result.ok) { + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.succeeded", + entityType: "agent", + entityId: agentId, + details: { source, sourceId, adapterType }, + }); + return; + } + + logger.warn( + { companyId, agentId, adapterType, source, sourceId, error: result.error, detail: result.detail }, + "hire hook: adapter returned failure", + ); + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.failed", + entityType: "agent", + entityId: agentId, + details: { source, sourceId, adapterType, error: result.error, detail: result.detail }, + }); + } catch (err) { + logger.error( + { err, companyId, agentId, adapterType, source, sourceId }, + "hire hook: adapter threw", + ); + await logActivity(db, { + companyId, + actorType: "system", + actorId: "hire_hook", + action: "hire_hook.error", + entityType: "agent", + entityId: agentId, + details: { + source, + sourceId, + adapterType, + error: err instanceof Error ? err.message : String(err), + }, + }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 486624d0..0dfe46ab 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,5 +1,5 @@ export { companyService } from "./companies.js"; -export { agentService } from "./agents.js"; +export { agentService, deduplicateAgentName } from "./agents.js"; export { assetService } from "./assets.js"; export { projectService } from "./projects.js"; export { issueService, type IssueFilters } from "./issues.js"; @@ -15,5 +15,6 @@ export { sidebarBadgeService } from "./sidebar-badges.js"; export { accessService } from "./access.js"; export { companyPortabilityService } from "./company-portability.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; +export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 33d8d130..cb258e23 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10,6 +10,7 @@ import { issueAttachments, issueLabels, issueComments, + issueReadStates, issues, labels, projectWorkspaces, @@ -49,6 +50,8 @@ export interface IssueFilters { status?: string; assigneeAgentId?: string; assigneeUserId?: string; + touchedByUserId?: string; + unreadForUserId?: string; projectId?: string; labelId?: string; q?: string; @@ -68,6 +71,17 @@ type IssueActiveRunRow = { }; type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] }; type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; +type IssueUserCommentStats = { + issueId: string; + myLastCommentAt: Date | null; + lastExternalCommentAt: Date | null; +}; +type IssueUserContextInput = { + createdByUserId: string | null; + assigneeUserId: string | null; + createdAt: Date | string; + updatedAt: Date | string; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -80,6 +94,127 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); } +function touchedByUserCondition(companyId: string, userId: string) { + return sql` + ( + ${issues.createdByUserId} = ${userId} + OR ${issues.assigneeUserId} = ${userId} + OR EXISTS ( + SELECT 1 + FROM ${issueReadStates} + WHERE ${issueReadStates.issueId} = ${issues.id} + AND ${issueReadStates.companyId} = ${companyId} + AND ${issueReadStates.userId} = ${userId} + ) + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorUserId} = ${userId} + ) + ) + `; +} + +function myLastCommentAtExpr(companyId: string, userId: string) { + return sql` + ( + SELECT MAX(${issueComments.createdAt}) + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorUserId} = ${userId} + ) + `; +} + +function myLastReadAtExpr(companyId: string, userId: string) { + return sql` + ( + SELECT MAX(${issueReadStates.lastReadAt}) + FROM ${issueReadStates} + WHERE ${issueReadStates.issueId} = ${issues.id} + AND ${issueReadStates.companyId} = ${companyId} + AND ${issueReadStates.userId} = ${userId} + ) + `; +} + +function myLastTouchAtExpr(companyId: string, userId: string) { + const myLastCommentAt = myLastCommentAtExpr(companyId, userId); + const myLastReadAt = myLastReadAtExpr(companyId, userId); + return sql` + GREATEST( + COALESCE(${myLastCommentAt}, to_timestamp(0)), + COALESCE(${myLastReadAt}, to_timestamp(0)), + COALESCE(CASE WHEN ${issues.createdByUserId} = ${userId} THEN ${issues.createdAt} ELSE NULL END, to_timestamp(0)), + COALESCE(CASE WHEN ${issues.assigneeUserId} = ${userId} THEN ${issues.updatedAt} ELSE NULL END, to_timestamp(0)) + ) + `; +} + +function unreadForUserCondition(companyId: string, userId: string) { + const touchedCondition = touchedByUserCondition(companyId, userId); + const myLastTouchAt = myLastTouchAtExpr(companyId, userId); + return sql` + ( + ${touchedCondition} + AND EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ( + ${issueComments.authorUserId} IS NULL + OR ${issueComments.authorUserId} <> ${userId} + ) + AND ${issueComments.createdAt} > ${myLastTouchAt} + ) + ) + `; +} + +export function deriveIssueUserContext( + issue: IssueUserContextInput, + userId: string, + stats: + | { + myLastCommentAt: Date | string | null; + myLastReadAt: Date | string | null; + lastExternalCommentAt: Date | string | null; + } + | null + | undefined, +) { + const normalizeDate = (value: Date | string | null | undefined) => { + if (!value) return null; + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const myLastCommentAt = normalizeDate(stats?.myLastCommentAt); + const myLastReadAt = normalizeDate(stats?.myLastReadAt); + const createdTouchAt = issue.createdByUserId === userId ? normalizeDate(issue.createdAt) : null; + const assignedTouchAt = issue.assigneeUserId === userId ? normalizeDate(issue.updatedAt) : null; + const myLastTouchAt = [myLastCommentAt, myLastReadAt, createdTouchAt, assignedTouchAt] + .filter((value): value is Date => value instanceof Date) + .sort((a, b) => b.getTime() - a.getTime())[0] ?? null; + const lastExternalCommentAt = normalizeDate(stats?.lastExternalCommentAt); + const isUnreadForMe = Boolean( + myLastTouchAt && + lastExternalCommentAt && + lastExternalCommentAt.getTime() > myLastTouchAt.getTime(), + ); + + return { + myLastTouchAt, + lastExternalCommentAt, + isUnreadForMe, + }; +} + async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise> { const map = new Map(); if (issueIds.length === 0) return map; @@ -284,6 +419,9 @@ export function issueService(db: Db) { return { list: async (companyId: string, filters?: IssueFilters) => { const conditions = [eq(issues.companyId, companyId)]; + const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; + const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; + const contextUserId = unreadForUserId ?? touchedByUserId; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -313,6 +451,12 @@ export function issueService(db: Db) { if (filters?.assigneeUserId) { conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); } + if (touchedByUserId) { + conditions.push(touchedByUserCondition(companyId, touchedByUserId)); + } + if (unreadForUserId) { + conditions.push(unreadForUserCondition(companyId, unreadForUserId)); + } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.labelId) { const labeledIssueIds = await db @@ -353,7 +497,102 @@ export function issueService(db: Db) { .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt)); const withLabels = await withIssueLabels(db, rows); const runMap = await activeRunMapForIssues(db, withLabels); - return withActiveRuns(withLabels, runMap); + const withRuns = withActiveRuns(withLabels, runMap); + if (!contextUserId || withRuns.length === 0) { + return withRuns; + } + + const issueIds = withRuns.map((row) => row.id); + const statsRows = await db + .select({ + issueId: issueComments.issueId, + myLastCommentAt: sql` + MAX(CASE WHEN ${issueComments.authorUserId} = ${contextUserId} THEN ${issueComments.createdAt} END) + `, + lastExternalCommentAt: sql` + MAX( + CASE + WHEN ${issueComments.authorUserId} IS NULL OR ${issueComments.authorUserId} <> ${contextUserId} + THEN ${issueComments.createdAt} + END + ) + `, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIds), + ), + ) + .groupBy(issueComments.issueId); + const readRows = await db + .select({ + issueId: issueReadStates.issueId, + myLastReadAt: issueReadStates.lastReadAt, + }) + .from(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.userId, contextUserId), + inArray(issueReadStates.issueId, issueIds), + ), + ); + const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); + const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt])); + + return withRuns.map((row) => ({ + ...row, + ...deriveIssueUserContext(row, contextUserId, { + myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, + myLastReadAt: readByIssueId.get(row.id) ?? null, + lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null, + }), + })); + }, + + countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => { + const conditions = [ + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + unreadForUserCondition(companyId, userId), + ]; + if (status) { + const statuses = status.split(",").map((s) => s.trim()).filter(Boolean); + if (statuses.length === 1) { + conditions.push(eq(issues.status, statuses[0])); + } else if (statuses.length > 1) { + conditions.push(inArray(issues.status, statuses)); + } + } + const [row] = await db + .select({ count: sql`count(*)` }) + .from(issues) + .where(and(...conditions)); + return Number(row?.count ?? 0); + }, + + markRead: async (companyId: string, issueId: string, userId: string, readAt: Date = new Date()) => { + const now = new Date(); + const [row] = await db + .insert(issueReadStates) + .values({ + companyId, + issueId, + userId, + lastReadAt: readAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId], + set: { + lastReadAt: readAt, + updatedAt: now, + }, + }) + .returning(); + return row; }, getById: async (id: string) => { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 3ff3b53b..54d5cd82 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow { primaryWorkspace: ProjectWorkspace | null; } +interface ProjectShortnameRow { + id: string; + name: string; +} + +interface ResolveProjectNameOptions { + excludeProjectId?: string | null; +} + /** Batch-load goal refs for a set of projects. */ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; @@ -192,6 +201,34 @@ function deriveWorkspaceName(input: { return "Workspace"; } +export function resolveProjectNameForUniqueShortname( + requestedName: string, + existingProjects: ProjectShortnameRow[], + options?: ResolveProjectNameOptions, +): string { + const requestedShortname = normalizeProjectUrlKey(requestedName); + if (!requestedShortname) return requestedName; + + const usedShortnames = new Set( + existingProjects + .filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId)) + .map((project) => normalizeProjectUrlKey(project.name)) + .filter((value): value is string => value !== null), + ); + if (!usedShortnames.has(requestedShortname)) return requestedName; + + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidateName = `${requestedName} ${suffix}`; + const candidateShortname = normalizeProjectUrlKey(candidateName); + if (candidateShortname && !usedShortnames.has(candidateShortname)) { + return candidateName; + } + } + + // Fallback guard for pathological naming collisions. + return `${requestedName} ${Date.now()}`; +} + async function ensureSinglePrimaryWorkspace( dbOrTx: any, input: { @@ -271,6 +308,12 @@ export function projectService(db: Db) { projectData.color = nextColor; } + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); + // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; @@ -295,6 +338,26 @@ export function projectService(db: Db) { ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + const existingProject = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null); + if (!existingProject) return null; + + if (projectData.name !== undefined) { + const existingShortname = normalizeProjectUrlKey(existingProject.name); + const nextShortname = normalizeProjectUrlKey(projectData.name); + if (existingShortname !== nextShortname) { + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, existingProject.companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, { + excludeProjectId: id, + }); + } + } // Keep legacy goalId column in sync const updates: Partial = { diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index 4ce60366..6429c5f2 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -1,7 +1,8 @@ -import { createReadStream, createWriteStream, promises as fs } from "node:fs"; +import { createReadStream, promises as fs } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { notFound } from "../errors.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; export type RunLogStoreType = "local_file"; @@ -113,11 +114,7 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore { stream: event.stream, chunk: event.chunk, }); - await new Promise((resolve, reject) => { - const stream = createWriteStream(absPath, { flags: "a", encoding: "utf8" }); - stream.on("error", reject); - stream.end(`${line}\n`, () => resolve()); - }); + await fs.appendFile(absPath, `${line}\n`, "utf8"); }, async finalize(handle) { @@ -152,7 +149,7 @@ let cachedStore: RunLogStore | null = null; export function getRunLogStore() { if (cachedStore) return cachedStore; - const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(process.cwd(), "data/run-logs"); + const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs"); cachedStore = createLocalFileRunLogStore(basePath); return cachedStore; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 8a3595b4..f18dcb18 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -308,10 +308,11 @@ export function secretService(db: Db) { return normalized; }, - resolveEnvBindings: async (companyId: string, envValue: unknown) => { + resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record; secretKeys: Set }> => { const record = asRecord(envValue); - if (!record) return {} as Record; + if (!record) return { env: {} as Record, secretKeys: new Set() }; const resolved: Record = {}; + const secretKeys = new Set(); for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { @@ -326,20 +327,22 @@ export function secretService(db: Db) { resolved[key] = binding.value; } else { resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + secretKeys.add(key); } } - return resolved; + return { env: resolved, secretKeys }; }, - resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record) => { + resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record): Promise<{ config: Record; secretKeys: Set }> => { const resolved = { ...adapterConfig }; + const secretKeys = new Set(); if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return resolved; + return { config: resolved, secretKeys }; } const record = asRecord(adapterConfig.env); if (!record) { resolved.env = {}; - return resolved; + return { config: resolved, secretKeys }; } const env: Record = {}; for (const [key, rawBinding] of Object.entries(record)) { @@ -355,10 +358,11 @@ export function secretService(db: Db) { env[key] = binding.value; } else { env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + secretKeys.add(key); } } resolved.env = env; - return resolved; + return { config: resolved, secretKeys }; }, }; } diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts index a9c87887..cd39bf57 100644 --- a/server/src/services/sidebar-badges.ts +++ b/server/src/services/sidebar-badges.ts @@ -10,7 +10,7 @@ export function sidebarBadgeService(db: Db) { return { get: async ( companyId: string, - extra?: { joinRequests?: number; assignedIssues?: number }, + extra?: { joinRequests?: number; unreadTouchedIssues?: number }, ): Promise => { const actionableApprovals = await db .select({ count: sql`count(*)` }) @@ -43,9 +43,9 @@ export function sidebarBadgeService(db: Db) { ).length; const joinRequests = extra?.joinRequests ?? 0; - const assignedIssues = extra?.assignedIssues ?? 0; + const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0; return { - inbox: actionableApprovals + failedRuns + joinRequests + assignedIssues, + inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues, approvals: actionableApprovals, failedRuns, joinRequests, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index b42355d9..bb3cbb04 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -16,6 +16,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. + **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. ## The Heartbeat Procedure @@ -89,6 +91,30 @@ Workspace rules: - For repo-only setup, omit `cwd` and provide `repoUrl`. - Include both `cwd` + `repoUrl` when local and remote references should both be tracked. +## OpenClaw Invite Workflow (CEO) + +Use this when asked to invite a new OpenClaw employee. + +1. Generate a fresh OpenClaw invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ "agentMessage": "optional onboarding note for OpenClaw" } +``` + +Access control: +- Board users with invite permission can call it. +- Agent callers: only the company CEO agent can call it. + +2. Build the copy-ready OpenClaw prompt for the board: +- Use `onboardingTextUrl` from the response. +- Ask the board to paste that prompt into OpenClaw. +- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`. + +3. Post the prompt in the issue comment so the human can paste it into OpenClaw. + +4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install). + ## Critical Rules - **Always checkout** before working. Never PATCH to `in_progress` manually. @@ -204,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path | Update task | `PATCH /api/issues/:issueId` (optional `comment` field) | | Add comment | `POST /api/issues/:issueId/comments` | | Create subtask | `POST /api/companies/:companyId/issues` | +| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` | | Create project | `POST /api/companies/:companyId/projects` | | Create project workspace | `POST /api/projects/:projectId/workspaces` | | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | @@ -222,6 +249,43 @@ GET /api/companies/{companyId}/issues?q=dockerfile Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`). +## Self-Test Playbook (App-Level) + +Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions). + +1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`): + +```bash +pnpm paperclipai issue create \ + --company-id "$PAPERCLIP_COMPANY_ID" \ + --title "Self-test: assignment/watch flow" \ + --description "Temporary validation issue" \ + --status todo \ + --assignee-agent-id "$PAPERCLIP_AGENT_ID" +``` + +2. Trigger and watch a heartbeat for that assignee: + +```bash +pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" +``` + +3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted: + +```bash +pnpm paperclipai issue get +``` + +4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: + +```bash +pnpm paperclipai issue update --assignee-agent-id --status todo +``` + +5. Cleanup: mark temporary issues done/cancelled with a clear note. + +If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat. + ## Full Reference For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md` diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a88abb82..cbf5ef05 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, Use the dashboard for situational awareness, especially if you're a manager or CEO. +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + --- ## Setting Agent Instructions Path @@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled` | GET | `/api/goals/:goalId` | Goal details | | POST | `/api/companies/:companyId/goals` | Create goal | | PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | ### Approvals, Costs, Activity, Dashboard diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md new file mode 100644 index 00000000..d28fa931 --- /dev/null +++ b/skills/release-changelog/SKILL.md @@ -0,0 +1,363 @@ +--- +name: release-changelog +description: > + Generate user-facing release changelogs for Paperclip. Reads git history, + merged PRs, and changeset files since the last release tag. Detects breaking + changes, categorizes changes, and outputs structured markdown to + releases/v{version}.md. Use when preparing a release or when asked to + generate a changelog. +--- + +# Release Changelog Skill + +Generate a user-facing changelog for a new Paperclip release. This skill reads +the commit history, changeset files, and merged PRs since the last release tag, +detects breaking changes, categorizes everything, and writes a structured +release notes file. + +**Output:** `releases/v{version}.md` in the repo root. +**Review required:** Always present the draft for human sign-off before +finalizing. Never auto-publish. + +--- + +## Step 0 — Idempotency Check + +Before generating anything, check if a changelog already exists for this version: + +```bash +ls releases/v{version}.md 2>/dev/null +``` + +**If the file already exists:** + +1. Read the existing changelog and present it to the reviewer. +2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it + as-is, (b) regenerate from scratch, or (c) update specific sections?" +3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is + done. +4. If the reviewer says regenerate → back up the existing file to + `releases/v{version}.md.prev`, then proceed from Step 1. +5. If the reviewer says update → read the existing file, proceed through Steps + 1-4 to gather fresh data, then merge changes into the existing file rather + than replacing it wholesale. Preserve any manual edits the reviewer previously + made. + +**If the file does not exist:** Proceed normally from Step 1. + +**Critical rule:** This skill NEVER triggers a version bump. It only reads git +history and writes a markdown file. The `release.sh` script is the only thing +that bumps versions, and it is called separately by the `release` coordination +skill. Running this skill multiple times is always safe — worst case it +overwrites a draft changelog (with reviewer permission). + +--- + +## Step 1 — Determine the Release Range + +Find the last release tag and the planned version: + +```bash +# Last release tag (most recent semver tag) +git tag --sort=-version:refname | head -1 +# e.g. v0.2.7 + +# All commits since that tag +git log v0.2.7..HEAD --oneline --no-merges +``` + +If no tag exists yet, use the initial commit as the base. + +The new version number comes from one of: +- An explicit argument (e.g. "generate changelog for v0.3.0") +- The bump type (patch/minor/major) applied to the last tag +- The version already set in `cli/package.json` if `scripts/release.sh` has been run + +--- + +## Step 2 — Gather Raw Change Data + +Collect changes from three sources, in priority order: + +### 2a. Git Commits + +```bash +git log v{last}..HEAD --oneline --no-merges +git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs +``` + +### 2b. Changeset Files + +Look for unconsumed changesets in `.changeset/`: + +```bash +ls .changeset/*.md | grep -v README.md +``` + +Each changeset file has YAML frontmatter with package names and bump types +(`patch`, `minor`, `major`), followed by a description. Parse these — the bump +type is a strong categorization signal, and the description may contain +user-facing summaries. + +### 2c. Merged PRs (when available) + +If GitHub access is available via `gh`: + +```bash +gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels +``` + +PR titles and bodies are often the best source of user-facing descriptions. +Prefer PR descriptions over raw commit messages when both are available. + +--- + +## Step 3 — Detect Breaking Changes + +Scan for breaking changes using these signals. **Any match flags the release as +containing breaking changes**, which affects version bump requirements and +changelog structure. + +### 3a. Migration Files + +Check for new migration files since the last tag: + +```bash +git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ +``` + +- **New migration files exist** = DB migration required in upgrade. +- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to + distinguish destructive vs. additive migrations. +- Additive-only migrations (new tables, new nullable columns, new indexes) are + safe but should still be mentioned. +- Destructive migrations (column drops, type changes, table drops) = breaking. + +### 3b. Schema Changes + +```bash +git diff v{last}..HEAD -- packages/db/src/schema/ +``` + +Look for: +- Removed or renamed columns/tables +- Changed column types +- Removed default values or nullable constraints +- These indicate breaking DB changes even if no explicit migration file exists + +### 3c. API Route Changes + +```bash +git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +``` + +Look for: +- Removed endpoints +- Changed request/response shapes (removed fields, type changes) +- Changed authentication requirements + +### 3d. Config Changes + +```bash +git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config* +``` + +Look for renamed, removed, or restructured configuration keys. + +### 3e. Changeset Severity + +Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking. + +### 3f. Commit Conventions + +Scan commit messages for: +- `BREAKING:` or `BREAKING CHANGE:` prefix +- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`) + +### Version Bump Rules + +| Condition | Minimum Bump | +|---|---| +| Destructive migration (DROP, RENAME) | `major` | +| Removed API endpoints or fields | `major` | +| Any `major` changeset or `BREAKING:` commit | `major` | +| New (additive) migration | `minor` | +| New features (`feat:` commits, `minor` changesets) | `minor` | +| Bug fixes only | `patch` | + +If the planned bump is lower than the minimum required, **warn the reviewer** +and recommend the correct bump level. + +--- + +## Step 4 — Categorize Changes + +Assign every meaningful change to one of these categories: + +| Category | What Goes Here | Shows in User Notes? | +|---|---|---| +| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) | +| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) | +| **Improvements** | Enhancements to existing features | Yes (bullet list) | +| **Fixes** | Bug fixes | Yes (bullet list) | +| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) | + +### Categorization Heuristics + +Use these signals to auto-categorize. When signals conflict, prefer the +higher-visibility category and flag for human review. + +| Signal | Category | +|---|---| +| Commit touches migration files, schema changes | Breaking Change (if destructive) | +| Changeset marked `major` | Breaking Change | +| Commit message has `BREAKING:` or `!:` | Breaking Change | +| New UI components, new routes, new API endpoints | Highlight | +| Commit message starts with `feat:` or `add:` | Highlight or Improvement | +| Changeset marked `minor` | Highlight | +| Commit message starts with `fix:` or `bug:` | Fix | +| Changeset marked `patch` | Fix or Improvement | +| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal | +| PR has detailed body with user-facing description | Use PR body as the description | + +### Writing Good Descriptions + +- **Highlights** get 1-2 sentence descriptions explaining the user benefit. + Write from the user's perspective ("You can now..." not "Added a component that..."). +- **Improvements and Fixes** are concise bullet points. +- **Breaking Changes** get detailed descriptions including what changed, + why, and what the user needs to do. +- Group related commits into a single changelog entry. Five commits implementing + one feature = one Highlight entry, not five bullets. +- Omit purely internal changes from user-facing notes entirely. + +--- + +## Step 5 — Write the Changelog + +Output the changelog to `releases/v{version}.md` using this template: + +```markdown +# v{version} + +> Released: {YYYY-MM-DD} + +{If breaking changes detected, include this section:} + +## Breaking Changes + +> **Action required before upgrading.** Read the Upgrade Guide below. + +- **{Breaking change title}** — {What changed and why. What the user needs to do.} + +## Highlights + +- **{Feature name}** — {1-2 sentence description of what it does and why it matters.} + +## Improvements + +- {Concise description of improvement} + +## Fixes + +- {Concise description of fix} + +--- + +{If breaking changes detected, include this section:} + +## Upgrade Guide + +### Before You Update + +1. **Back up your database.** + - SQLite: `cp paperclip.db paperclip.db.backup` + - Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump` +2. **Note your current version:** `paperclip --version` + +### After Updating + +{Specific steps: run migrations, update configs, etc.} + +### Rolling Back + +If something goes wrong: +1. Restore your database backup +2. `npm install @paperclipai/server@{previous-version}` +``` + +### Template Rules + +- Omit any empty section entirely (don't show "## Fixes" with no bullets). +- The Breaking Changes section always comes first when present. +- The Upgrade Guide always comes last when present. +- Use `**bold**` for feature/change names, regular text for descriptions. +- Keep the entire changelog scannable — a busy user should get the gist from + headings and bold text alone. + +--- + +## Step 6 — Present for Review + +After generating the draft: + +1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release). +2. **Flag ambiguous items** — commits you weren't sure how to categorize, or + items that might be breaking but aren't clearly signaled. +3. **Flag version bump mismatches** — if the planned bump is lower than what + the changes warrant. +4. **Wait for approval** before considering the changelog final. + +If the reviewer requests edits, update `releases/v{version}.md` accordingly. + +Do not proceed to publishing, website updates, or social announcements. Those +are handled by the `release` coordination skill (separate from this one). + +--- + +## Directory Convention + +Release changelogs live in `releases/` at the repo root: + +``` +releases/ + v0.2.7.md + v0.3.0.md + ... +``` + +Each file is named `v{version}.md` matching the git tag. This directory is +committed to the repo and serves as the source of truth for release history. + +The `releases/` directory should be created with a `.gitkeep` if it doesn't +exist yet. + +--- + +## Quick Reference + +```bash +# Full workflow summary: + +# 1. Find last tag +LAST_TAG=$(git tag --sort=-version:refname | head -1) + +# 2. Commits since last tag +git log $LAST_TAG..HEAD --oneline --no-merges + +# 3. Files changed (for breaking change detection) +git diff --name-only $LAST_TAG..HEAD + +# 4. Migration changes specifically +git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/ + +# 5. Schema changes +git diff $LAST_TAG..HEAD -- packages/db/src/schema/ + +# 6. Unconsumed changesets +ls .changeset/*.md | grep -v README.md + +# 7. Merged PRs (if gh available) +gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \ + --json number,title,body,labels +``` diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md new file mode 100644 index 00000000..4c91fffd --- /dev/null +++ b/skills/release/SKILL.md @@ -0,0 +1,402 @@ +--- +name: release +description: > + Coordinate a full Paperclip release across engineering, website publishing, + and social announcement. Use when CTO/CEO requests "do a release" or + "release vX.Y.Z". Runs pre-flight checks, generates changelog via + release-changelog, executes npm release, creates cross-project follow-up + tasks, and posts a release wrap-up. +--- + +# Release Coordination Skill + +Run the full Paperclip release process as an organizational workflow, not just +an npm publish. + +This skill coordinates: +- User-facing changelog generation (`release-changelog` skill) +- Canary publish to npm (`scripts/release.sh --canary`) +- Docker smoke test of the canary (`scripts/docker-onboard-smoke.sh`) +- Promotion to `latest` after canary is verified +- Website publishing task creation +- CMO announcement task creation +- Final release summary with links + +--- + +## Trigger + +Use this skill when leadership asks for: +- "do a release" +- "release {patch|minor|major}" +- "release vX.Y.Z" + +--- + +## Preconditions + +Before proceeding, verify all of the following: + +1. `skills/release-changelog/SKILL.md` exists and is usable. +2. The `release-changelog` dependency work is complete/reviewed before running this flow. +3. App repo working tree is clean. +4. There are commits since the last release tag. +5. You have release permissions (`npm whoami` succeeds for real publish). +6. If running via Paperclip, you have issue context for posting status updates. + +If any precondition fails, stop and report the blocker. + +--- + +## Inputs + +Collect these inputs up front: + +- Release request source issue (if in Paperclip) +- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`) +- Whether this run is dry-run or live publish +- Company/project context for follow-up issue creation + +--- + +## Step 0 — Idempotency Guards + +Each step in this skill is designed to be safely re-runnable. Before executing +any step, check whether it has already been completed: + +| Step | How to Check | If Already Done | +|---|---|---| +| Changelog | `releases/v{version}.md` exists | Read it, ask reviewer to confirm or update. Do NOT regenerate without asking. | +| Canary publish | `npm view paperclipai@{version}` succeeds | Skip canary publish. Proceed to smoke test. | +| Smoke test | Manual or scripted verification | If canary already verified, proceed to promote. | +| Promote | `git tag v{version}` exists | Skip promotion entirely. A tag means the version is already promoted to latest. | +| Website task | Search Paperclip issues for "Publish release notes for v{version}" | Skip creation. Link the existing task. | +| CMO task | Search Paperclip issues for "release announcement tweet for v{version}" | Skip creation. Link the existing task. | + +**The golden rule:** If a git tag `v{version}` already exists, the release is +fully promoted. Only post-publish tasks (website, CMO, wrap-up) should proceed. +If the version exists on npm but there's no git tag, the canary was published but +not yet promoted — resume from smoke test. + +**Iterating on changelogs:** You can re-run this skill with an existing changelog +to refine it _before_ the npm publish step. The `release-changelog` skill has +its own idempotency check and will ask the reviewer what to do with an existing +file. This is the expected workflow for iterating on release notes. + +--- + +## Step 1 - Pre-flight and Version Decision + +Run pre-flight in the App repo root: + +```bash +LAST_TAG=$(git tag --sort=-version:refname | head -1) +git diff --quiet && git diff --cached --quiet +git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50 +``` + +Then detect minimum required bump: + +```bash +# migrations +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ + +# schema deltas +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ + +# breaking commit conventions +git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Bump policy: +- Destructive migration/API removal/major changeset/breaking commit -> `major` +- Additive migrations or clear new features -> at least `minor` +- Fixes-only -> `patch` + +If requested bump is lower than required minimum, escalate bump and explain why. + +--- + +## Step 2 - Generate Changelog Draft + +First, check if `releases/v{version}.md` already exists. If it does, the +`release-changelog` skill will detect this and ask the reviewer whether to keep, +regenerate, or update it. **Do not silently overwrite an existing changelog.** + +Invoke the `release-changelog` skill and produce: +- `releases/v{version}.md` +- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) + +Required behavior: +- Present the draft for human review. +- Flag ambiguous categorization items. +- Flag bump mismatches before publish. +- Do not publish until reviewer confirms. + +--- + +## Step 3 — Publish Canary + +The canary is the gatekeeper: every release goes to npm as a canary first. The +`latest` tag is never touched until the canary passes smoke testing. + +**Idempotency check:** Before publishing, check if this version already exists +on npm: + +```bash +# Check if canary is already published +npm view paperclipai@{version} version 2>/dev/null && echo "ALREADY_PUBLISHED" || echo "NOT_PUBLISHED" + +# Also check git tag +git tag -l "v{version}" +``` + +- If a git tag exists → the release is already fully promoted. Skip to Step 6. +- If the version exists on npm but no git tag → canary was published but not yet + promoted. Skip to Step 4 (smoke test). +- If neither exists → proceed with canary publish. + +### Publishing the canary + +Use `release.sh` with the `--canary` flag (see script changes below): + +```bash +# Dry run first +./scripts/release.sh {patch|minor|major} --canary --dry-run + +# Publish canary (after dry-run review) +./scripts/release.sh {patch|minor|major} --canary +``` + +This publishes all packages to npm with the `canary` dist-tag. The `latest` tag +is **not** updated. Users running `npx paperclipai onboard` still get the +previous stable version. + +After publish, verify the canary is accessible: + +```bash +npm view paperclipai@canary version +# Should show the new version +``` + +**How `--canary` works in release.sh:** +- Steps 1-5 are the same (preflight, changeset, version, build, CLI bundle) +- Step 6 uses `npx changeset publish --tag canary` instead of `npx changeset publish` +- Step 7 does NOT commit or tag — the commit and tag happen later in the promote + step, only after smoke testing passes + +**Script changes required:** Add `--canary` support to `scripts/release.sh`: +- Parse `--canary` flag alongside `--dry-run` +- When `--canary`: pass `--tag canary` to `changeset publish` +- When `--canary`: skip the git commit and tag step (Step 7) +- When NOT `--canary`: behavior is unchanged (backwards compatible) + +--- + +## Step 4 — Smoke Test the Canary + +Run the canary in a clean Docker environment to verify `npx paperclipai onboard` +works end-to-end. + +### Automated smoke test + +Use the existing Docker smoke test infrastructure with the canary version: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +This builds a clean Ubuntu container, installs `paperclipai@canary` via npx, and +runs the onboarding flow. The UI is accessible at `http://localhost:3131`. + +### What to verify + +At minimum, confirm: + +1. **Container starts** — no npm install errors, no missing dependencies +2. **Onboarding completes** — the wizard runs through without crashes +3. **Server boots** — UI is accessible at the expected port +4. **Basic operations** — can create a company, view the dashboard + +For a more thorough check (stretch goal — can be automated later): + +5. **Browser automation** — script Playwright/Puppeteer to walk through onboard + in the Docker container's browser and verify key pages render + +### If smoke test fails + +- Do NOT promote the canary. +- Fix the issue, publish a new canary (re-run Step 3 — idempotency guards allow + this since there's no git tag yet). +- Re-run the smoke test. + +### If smoke test passes + +Proceed to Step 5 (promote). + +--- + +## Step 5 — Promote Canary to Latest + +Once the canary passes smoke testing, promote it to `latest` so that +`npx paperclipai onboard` picks up the new version. + +### Promote on npm + +```bash +# For each published package, move the dist-tag from canary to latest +npm dist-tag add paperclipai@{version} latest +npm dist-tag add @paperclipai/server@{version} latest +npm dist-tag add @paperclipai/cli@{version} latest +npm dist-tag add @paperclipai/shared@{version} latest +npm dist-tag add @paperclipai/db@{version} latest +npm dist-tag add @paperclipai/adapter-utils@{version} latest +npm dist-tag add @paperclipai/adapter-claude-local@{version} latest +npm dist-tag add @paperclipai/adapter-codex-local@{version} latest +npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest +``` + +**Script option:** Add `./scripts/release.sh --promote {version}` to automate +the dist-tag promotion for all packages. + +### Commit and tag + +After promotion, finalize in git (this is what `release.sh` Step 7 normally +does, but was deferred during canary publish): + +```bash +git add . +git commit -m "chore: release v{version}" +git tag "v{version}" +``` + +### Verify promotion + +```bash +npm view paperclipai@latest version +# Should now show the new version + +# Final sanity check +npx --yes paperclipai@latest --version +``` + +--- + +## Step 6 - Create Cross-Project Follow-up Tasks + +**Idempotency check:** Before creating tasks, search for existing ones: + +``` +GET /api/companies/{companyId}/issues?q=release+notes+v{version} +GET /api/companies/{companyId}/issues?q=announcement+tweet+v{version} +``` + +If matching tasks already exist (check title contains the version), skip +creation and link the existing tasks instead. Do not create duplicates. + +Create at least two tasks in Paperclip (only if they don't already exist): + +1. Website task: publish changelog for `v{version}` +2. CMO task: draft announcement tweet for `v{version}` + +When creating tasks: +- Set `parentId` to the release issue id. +- Carry over `goalId` from the parent issue when present. +- Include `billingCode` for cross-team work when required by company policy. +- Mark website task `high` priority if release has breaking changes. + +Suggested payloads: + +```json +POST /api/companies/{companyId}/issues +{ + "projectId": "{websiteProjectId}", + "parentId": "{releaseIssueId}", + "goalId": "{goalId-or-null}", + "billingCode": "{billingCode-or-null}", + "title": "Publish release notes for v{version}", + "priority": "medium", + "status": "todo", + "description": "Publish /changelog entry for v{version}. Include full markdown from releases/v{version}.md and prominent upgrade guide if breaking changes exist." +} +``` + +```json +POST /api/companies/{companyId}/issues +{ + "projectId": "{workspaceProjectId}", + "parentId": "{releaseIssueId}", + "goalId": "{goalId-or-null}", + "billingCode": "{billingCode-or-null}", + "title": "Draft release announcement tweet for v{version}", + "priority": "medium", + "status": "todo", + "description": "Draft launch tweet with top 1-2 highlights, version number, and changelog URL. If breaking changes exist, include an explicit upgrade-guide callout." +} +``` + +--- + +## Step 7 - Wrap Up the Release Issue + +Post a concise markdown update linking: +- Release issue +- Changelog file (`releases/v{version}.md`) +- npm package URL (both `@canary` and `@latest` after promotion) +- Canary smoke test result (pass/fail, what was tested) +- Website task +- CMO task +- Final changelog URL (once website publishes) +- Tweet URL (once published) + +Completion rules: +- Keep issue `in_progress` until canary is promoted AND website + social tasks + are done. +- Mark `done` only when all required artifacts are published and linked. +- If waiting on another team, keep open with clear owner and next action. + +--- + +## Release Flow Summary + +The full release lifecycle is now: + +``` +1. Generate changelog → releases/v{version}.md (review + iterate) +2. Publish canary → npm @canary dist-tag (latest untouched) +3. Smoke test canary → Docker clean install verification +4. Promote to latest → npm @latest dist-tag + git tag + commit +5. Create follow-up tasks → website changelog + CMO tweet +6. Wrap up → link everything, close issue +``` + +At any point you can re-enter the flow — idempotency guards detect which steps +are already done and skip them. The changelog can be iterated before or after +canary publish. The canary can be re-published if the smoke test reveals issues +(just fix + re-run Step 3). Only after smoke testing passes does `latest` get +updated. + +--- + +## Paperclip API Notes (When Running in Agent Context) + +Use: +- `GET /api/companies/{companyId}/projects` to resolve website/workspace project IDs. +- `POST /api/companies/{companyId}/issues` to create follow-up tasks. +- `PATCH /api/issues/{issueId}` with comments for release progress. + +For issue-modifying calls, include: +- `Authorization: Bearer $PAPERCLIP_API_KEY` +- `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` + +--- + +## Failure Handling + +If blocked, update the release issue explicitly with: +- what failed +- exact blocker +- who must act next +- whether any release artifacts were partially published + +Never silently fail mid-release. diff --git a/ui/package.json b/ui/package.json index ccd40dd7..e34b0d50 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,7 +18,8 @@ "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", - "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-pi-local": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.574.0", + "mermaid": "^11.12.0", "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/ui/public/brands/opencode-logo-dark-square.svg b/ui/public/brands/opencode-logo-dark-square.svg new file mode 100644 index 00000000..6a67f627 --- /dev/null +++ b/ui/public/brands/opencode-logo-dark-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/public/brands/opencode-logo-light-square.svg b/ui/public/brands/opencode-logo-light-square.svg new file mode 100644 index 00000000..a738ad87 --- /dev/null +++ b/ui/public/brands/opencode-logo-light-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest index 8861bd64..907f6293 100644 --- a/ui/public/site.webmanifest +++ b/ui/public/site.webmanifest @@ -1,6 +1,14 @@ { + "id": "/", "name": "Paperclip", "short_name": "Paperclip", + "description": "AI-powered project management and agent coordination platform", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#18181b", + "background_color": "#18181b", "icons": [ { "src": "/android-chrome-192x192.png", @@ -11,9 +19,12 @@ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } - ], - "theme_color": "#18181b", - "background_color": "#18181b", - "display": "standalone" + ] } diff --git a/ui/public/sw.js b/ui/public/sw.js new file mode 100644 index 00000000..f90d1215 --- /dev/null +++ b/ui/public/sw.js @@ -0,0 +1,42 @@ +const CACHE_NAME = "paperclip-v2"; + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.map((key) => caches.delete(key))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests and API calls + if (request.method !== "GET" || url.pathname.startsWith("/api")) { + return; + } + + // Network-first for everything — cache is only an offline fallback + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok && url.origin === self.location.origin) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(() => { + if (request.mode === "navigate") { + return caches.match("/") || new Response("Offline", { status: 503 }); + } + return caches.match(request); + }) + ); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 02baefb6..18df83d8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { OrgChart } from "./pages/OrgChart"; +import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; @@ -101,6 +102,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -214,6 +216,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx new file mode 100644 index 00000000..178f9f61 --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -0,0 +1,217 @@ +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, + help, +} from "../../components/agent-config-primitives"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +function SecretField({ + label, + value, + onCommit, + placeholder, +}: { + label: string; + value: string; + onCommit: (v: string) => void; + placeholder?: string; +}) { + const [visible, setVisible] = useState(false); + return ( + +
+ + +
+
+ ); +} + +function parseScopes(value: unknown): string { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string").join(", "); + } + return typeof value === "string" ? value : ""; +} + +export function OpenClawGatewayConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + const configuredHeaders = + config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) + ? (config.headers as Record) + : {}; + const effectiveHeaders = + (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; + + const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string" + ? String(effectiveHeaders["x-openclaw-token"]) + : typeof effectiveHeaders["x-openclaw-auth"] === "string" + ? String(effectiveHeaders["x-openclaw-auth"]) + : ""; + + const commitGatewayToken = (rawValue: string) => { + const nextValue = rawValue.trim(); + const nextHeaders: Record = { ...effectiveHeaders }; + if (nextValue) { + nextHeaders["x-openclaw-token"] = nextValue; + delete nextHeaders["x-openclaw-auth"]; + } else { + delete nextHeaders["x-openclaw-token"]; + delete nextHeaders["x-openclaw-auth"]; + } + mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); + }; + + const sessionStrategy = eff( + "adapterConfig", + "sessionKeyStrategy", + String(config.sessionKeyStrategy ?? "fixed"), + ); + + return ( + <> + + + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) + } + immediate + className={inputClass} + placeholder="ws://127.0.0.1:18789" + /> + + + {!isCreate && ( + <> + + mark("adapterConfig", "paperclipApiUrl", v || undefined)} + immediate + className={inputClass} + placeholder="https://paperclip.example" + /> + + + + + + + {sessionStrategy === "fixed" && ( + + mark("adapterConfig", "sessionKey", v || undefined)} + immediate + className={inputClass} + placeholder="paperclip" + /> + + )} + + + + + mark("adapterConfig", "role", v || undefined)} + immediate + className={inputClass} + placeholder="operator" + /> + + + + { + const parsed = v + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined); + }} + immediate + className={inputClass} + placeholder="operator.admin" + /> + + + + { + const parsed = Number.parseInt(v.trim(), 10); + mark( + "adapterConfig", + "waitTimeoutMs", + Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + ); + }} + immediate + className={inputClass} + placeholder="120000" + /> + + + +
+ Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals + remain stable across runs. +
+
+ + )} + + ); +} diff --git a/ui/src/adapters/openclaw-gateway/index.ts b/ui/src/adapters/openclaw-gateway/index.ts new file mode 100644 index 00000000..812f7de0 --- /dev/null +++ b/ui/src/adapters/openclaw-gateway/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui"; +import { OpenClawGatewayConfigFields } from "./config-fields"; + +export const openClawGatewayUIAdapter: UIAdapterModule = { + type: "openclaw_gateway", + label: "OpenClaw Gateway", + parseStdoutLine: parseOpenClawGatewayStdoutLine, + ConfigFields: OpenClawGatewayConfigFields, + buildAdapterConfig: buildOpenClawGatewayConfig, +}; diff --git a/ui/src/adapters/openclaw/config-fields.tsx b/ui/src/adapters/openclaw/config-fields.tsx deleted file mode 100644 index abad6b12..00000000 --- a/ui/src/adapters/openclaw/config-fields.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { AdapterConfigFieldsProps } from "../types"; -import { - Field, - DraftInput, - help, -} from "../../components/agent-config-primitives"; - -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; - -export function OpenClawConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, -}: AdapterConfigFieldsProps) { - return ( - <> - - - isCreate - ? set!({ url: v }) - : mark("adapterConfig", "url", v || undefined) - } - immediate - className={inputClass} - placeholder="https://..." - /> - - {!isCreate && ( - - mark("adapterConfig", "webhookAuthHeader", v || undefined)} - immediate - className={inputClass} - placeholder="Bearer " - /> - - )} - - ); -} diff --git a/ui/src/adapters/openclaw/index.ts b/ui/src/adapters/openclaw/index.ts deleted file mode 100644 index 890d83bc..00000000 --- a/ui/src/adapters/openclaw/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui"; -import { OpenClawConfigFields } from "./config-fields"; - -export const openClawUIAdapter: UIAdapterModule = { - type: "openclaw", - label: "OpenClaw", - parseStdoutLine: parseOpenClawStdoutLine, - ConfigFields: OpenClawConfigFields, - buildAdapterConfig: buildOpenClawConfig, -}; diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx index e408c113..043e91c1 100644 --- a/ui/src/adapters/opencode-local/config-fields.tsx +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -8,7 +8,7 @@ import { ChoosePathButton } from "../../components/PathInstructionsModal"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the prompt at runtime."; + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; export function OpenCodeLocalConfigFields({ isCreate, diff --git a/ui/src/adapters/pi-local/config-fields.tsx b/ui/src/adapters/pi-local/config-fields.tsx new file mode 100644 index 00000000..e6afacb3 --- /dev/null +++ b/ui/src/adapters/pi-local/config-fields.tsx @@ -0,0 +1,47 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function PiLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/pi-local/index.ts b/ui/src/adapters/pi-local/index.ts new file mode 100644 index 00000000..cfebf669 --- /dev/null +++ b/ui/src/adapters/pi-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parsePiStdoutLine } from "@paperclipai/adapter-pi-local/ui"; +import { PiLocalConfigFields } from "./config-fields"; +import { buildPiLocalConfig } from "@paperclipai/adapter-pi-local/ui"; + +export const piLocalUIAdapter: UIAdapterModule = { + type: "pi_local", + label: "Pi (local)", + parseStdoutLine: parsePiStdoutLine, + ConfigFields: PiLocalConfigFields, + buildAdapterConfig: buildPiLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 2ce643f0..1a36af6b 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,12 +3,22 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { openCodeLocalUIAdapter } from "./opencode-local"; -import { openClawUIAdapter } from "./openclaw"; +import { piLocalUIAdapter } from "./pi-local"; +import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [ + claudeLocalUIAdapter, + codexLocalUIAdapter, + openCodeLocalUIAdapter, + piLocalUIAdapter, + cursorLocalUIAdapter, + openClawGatewayUIAdapter, + processUIAdapter, + httpUIAdapter, + ].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 58cff1b5..143f472a 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -3,9 +3,9 @@ import type { TranscriptEntry, StdoutLineParser } from "./types"; type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { - if (entry.kind === "thinking" && entry.delta) { + if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; - if (last && last.kind === "thinking" && last.delta) { + if (last && last.kind === entry.kind && last.delta) { last.text += entry.text; last.ts = entry.ts; return; diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 6b66a0f1..ce565f6d 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -13,6 +13,7 @@ type InviteSummary = { onboardingTextUrl?: string; skillIndexPath?: string; skillIndexUrl?: string; + inviteMessage?: string | null; }; type AcceptInviteInput = @@ -39,7 +40,21 @@ type AgentJoinRequestAccepted = JoinRequest & { type InviteOnboardingManifest = { invite: InviteSummary; - onboarding: Record; + onboarding: { + inviteMessage?: string | null; + connectivity?: { + guidance?: string; + connectionCandidates?: string[]; + testResolutionEndpoint?: { + method?: string; + path?: string; + url?: string; + }; + }; + textInstructions?: { + url?: string; + }; + }; }; type BoardClaimStatus = { @@ -49,22 +64,38 @@ type BoardClaimStatus = { claimedByUserId: string | null; }; +type CompanyInviteCreated = { + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, input: { allowedJoinTypes?: "human" | "agent" | "both"; - expiresInHours?: number; defaultsPayload?: Record | null; + agentMessage?: string | null; } = {}, ) => - api.post<{ - id: string; - token: string; - inviteUrl: string; - expiresAt: string; - allowedJoinTypes: "human" | "agent" | "both"; - }>(`/companies/${companyId}/invites`, input), + api.post(`/companies/${companyId}/invites`, input), + + createOpenClawInvitePrompt: ( + companyId: string, + input: { + agentMessage?: string | null; + } = {}, + ) => + api.post( + `/companies/${companyId}/openclaw/invite-prompt`, + input, + ), getInvite: (token: string) => api.get(`/invites/${token}`), getInviteOnboarding: (token: string) => diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0b91f694..85486af9 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -117,7 +117,10 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (type: string) => api.get(`/adapters/${type}/models`), + adapterModels: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 05175f71..941294e6 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -9,6 +9,8 @@ export const issuesApi = { projectId?: string; assigneeAgentId?: string; assigneeUserId?: string; + touchedByUserId?: string; + unreadForUserId?: string; labelId?: string; q?: string; }, @@ -18,6 +20,8 @@ export const issuesApi = { if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); + if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); + if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.labelId) params.set("labelId", filters.labelId); if (filters?.q) params.set("q", filters.q); const qs = params.toString(); @@ -28,6 +32,7 @@ export const issuesApi = { api.post(`/companies/${companyId}/labels`, data), deleteLabel: (id: string) => api.delete(`/labels/${id}`), get: (id: string) => api.get(`/issues/${id}`), + markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index d9db179d..2c382a9e 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -21,9 +21,13 @@ interface FeedItem { agentName: string; text: string; tone: FeedTone; + dedupeKey: string; + streamingKind?: "assistant" | "thinking"; } const MAX_FEED_ITEMS = 40; +const MAX_FEED_TEXT_LENGTH = 220; +const MAX_STREAMING_TEXT_LENGTH = 4000; const MIN_DASHBOARD_RUNS = 4; function readString(value: unknown): string | null { @@ -70,17 +74,25 @@ function createFeedItem( text: string, tone: FeedTone, nextId: number, + options?: { + streamingKind?: "assistant" | "thinking"; + preserveWhitespace?: boolean; + }, ): FeedItem | null { - const trimmed = text.trim(); - if (!trimmed) return null; + if (!text.trim()) return null; + const base = options?.preserveWhitespace ? text : text.trim(); + const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; + const normalized = base.length > maxLength ? base.slice(-maxLength) : base; return { id: `${run.id}:${nextId}`, ts, runId: run.id, agentId: run.agentId, agentName: run.agentName, - text: trimmed.slice(0, 220), + text: normalized, tone, + dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, + streamingKind: options?.streamingKind, }; } @@ -97,16 +109,28 @@ function parseStdoutChunk( pendingByRun.set(pendingKey, split.pop() ?? ""); const adapter = getUIAdapter(run.adapterType); - const summarized: Array<{ text: string; tone: FeedTone; thinkingDelta?: boolean }> = []; + const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; const appendSummary = (entry: TranscriptEntry) => { + if (entry.kind === "assistant" && entry.delta) { + const text = entry.text; + if (!text.trim()) return; + const last = summarized[summarized.length - 1]; + if (last && last.streamingKind === "assistant") { + last.text += text; + } else { + summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); + } + return; + } + if (entry.kind === "thinking" && entry.delta) { const text = entry.text; if (!text.trim()) return; const last = summarized[summarized.length - 1]; - if (last && last.thinkingDelta) { + if (last && last.streamingKind === "thinking") { last.text += text; } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", thinkingDelta: true }); + summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); } return; } @@ -132,7 +156,10 @@ function parseStdoutChunk( } for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++); + const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { + streamingKind: summary.streamingKind, + preserveWhitespace: !!summary.streamingKind, + }); if (item) items.push(item); } @@ -222,8 +249,38 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { if (items.length === 0) return; setFeedByRun((prev) => { const next = new Map(prev); - const existing = next.get(runId) ?? []; - next.set(runId, [...existing, ...items].slice(-MAX_FEED_ITEMS)); + const existing = [...(next.get(runId) ?? [])]; + for (const item of items) { + if (seenKeysRef.current.has(item.dedupeKey)) continue; + seenKeysRef.current.add(item.dedupeKey); + + const last = existing[existing.length - 1]; + if ( + item.streamingKind && + last && + last.runId === item.runId && + last.streamingKind === item.streamingKind + ) { + const mergedText = `${last.text}${item.text}`; + const nextText = + mergedText.length > MAX_STREAMING_TEXT_LENGTH + ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) + : mergedText; + existing[existing.length - 1] = { + ...last, + ts: item.ts, + text: nextText, + dedupeKey: last.dedupeKey, + }; + continue; + } + + existing.push(item); + } + if (seenKeysRef.current.size > 6000) { + seenKeysRef.current.clear(); + } + next.set(runId, existing.slice(-MAX_FEED_ITEMS)); return next; }); }; @@ -265,7 +322,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; if (seenKeysRef.current.has(dedupeKey)) return; seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear(); + if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); if (item) appendItems(run.id, [item]); @@ -277,7 +334,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; if (seenKeysRef.current.has(dedupeKey)) return; seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear(); + if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); if (item) appendItems(run.id, [item]); @@ -404,7 +461,7 @@ function AgentRunCard({ getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type - const { data: fetchedModels } = useQuery({ - queryKey: ["adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(adapterType), + const { + data: fetchedModels, + error: fetchedModelsError, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) + : ["agents", "none", "adapter-models", adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; @@ -341,17 +348,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "modelReasoningEffort" : adapterType === "cursor" ? "mode" - : adapterType === "opencode_local" - ? "variant" - : "effort"; + : adapterType === "opencode_local" + ? "variant" + : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions : adapterType === "cursor" ? cursorModeOptions - : adapterType === "opencode_local" - ? opencodeVariantOptions - : claudeThinkingEffortOptions; + : adapterType === "opencode_local" + ? openCodeThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -362,8 +369,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ) : adapterType === "cursor" ? eff("adapterConfig", "mode", String(config.mode ?? "")) - : adapterType === "opencode_local" - ? eff("adapterConfig", "variant", String(config.variant ?? "")) + : adapterType === "opencode_local" + ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) @@ -436,7 +443,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "promptTemplate", String(config.promptTemplate ?? ""), )} - onChange={(v) => mark("adapterConfig", "promptTemplate", v || undefined)} + onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-[88px] text-sm font-mono" imageUploadHandler={async (file) => { @@ -485,7 +492,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { - nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; + nextValues.model = ""; } set!(nextValues); } else { @@ -500,9 +507,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? DEFAULT_CODEX_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL - : t === "opencode_local" - ? DEFAULT_OPENCODE_LOCAL_MODEL - : "", + : "", effort: "", modelReasoningEffort: "", variant: "", @@ -607,9 +612,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "codex" : adapterType === "cursor" ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude" + : adapterType === "opencode_local" + ? "opencode" + : "claude" } /> @@ -624,7 +629,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} + groupByProvider={adapterType === "opencode_local"} /> + {fetchedModelsError && ( +

+ {fetchedModelsError instanceof Error + ? fetchedModelsError.message + : "Failed to load adapter models."} +

+ )} @@ -920,7 +938,10 @@ function AdapterTypeDropdown({ if (!item.comingSoon) onChange(item.value); }} > - {item.label} + + {item.value === "opencode_local" ? : null} + {item.label} + {item.comingSoon && ( Coming soon )} @@ -1186,20 +1207,56 @@ function ModelDropdown({ onChange, open, onOpenChange, + allowDefault, + required, + groupByProvider, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; + allowDefault: boolean; + required: boolean; + groupByProvider: boolean; }) { const [modelSearch, setModelSearch] = useState(""); const selected = models.find((m) => m.id === value); - const filteredModels = models.filter((m) => { - if (!modelSearch.trim()) return true; - const q = modelSearch.toLowerCase(); - return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q); - }); + const filteredModels = useMemo(() => { + return models.filter((m) => { + if (!modelSearch.trim()) return true; + const q = modelSearch.toLowerCase(); + const provider = extractProviderId(m.id) ?? ""; + return ( + m.id.toLowerCase().includes(q) || + m.label.toLowerCase().includes(q) || + provider.toLowerCase().includes(q) + ); + }); + }, [models, modelSearch]); + const groupedModels = useMemo(() => { + if (!groupByProvider) { + return [ + { + provider: "models", + entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), + }, + ]; + } + const map = new Map(); + for (const model of filteredModels) { + const provider = extractProviderId(model.id) ?? "other"; + const group = map.get(provider) ?? []; + group.push(model); + map.set(provider, group); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, entries]) => ({ + provider, + entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), + })); + }, [filteredModels, groupByProvider]); return ( @@ -1213,7 +1270,9 @@ function ModelDropdown({ @@ -1227,33 +1286,45 @@ function ModelDropdown({ autoFocus />
- - {filteredModels.map((m) => ( + {allowDefault && ( + )} + {groupedModels.map((group) => ( +
+ {groupByProvider && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
))} {filteredModels.length === 0 && (

No models found.

diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index c7fa3647..b49b2b10 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@/lib/router"; -import type { Agent, AgentRuntimeState } from "@paperclipai/shared"; +import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared"; import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; @@ -18,12 +18,14 @@ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", - openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", }; +const roleLabels = AGENT_ROLE_LABELS as Record; + function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -51,7 +53,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { - {agent.role} + {roleLabels[agent.role] ?? agent.role} {agent.title && ( diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index d4199e7e..3defb0e6 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; +import { useSidebar } from "../context/SidebarContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; @@ -37,6 +38,7 @@ export function CommandPalette() { const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); const { openNewIssue, openNewAgent } = useDialog(); + const { isMobile, setSidebarOpen } = useSidebar(); const searchQuery = query.trim(); useEffect(() => { @@ -44,11 +46,12 @@ export function CommandPalette() { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen(true); + if (isMobile) setSidebarOpen(false); } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, []); + }, [isMobile, setSidebarOpen]); useEffect(() => { if (!open) setQuery(""); @@ -94,7 +97,10 @@ export function CommandPalette() { ); return ( - + { + setOpen(v); + if (v && isMobile) setSidebarOpen(false); + }}> {issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); - return name ? : null; + return name ? : null; })()} ))} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b96c1af0..fa123d31 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -2,12 +2,13 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re import { Link, useLocation } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; -import { Paperclip } from "lucide-react"; +import { Check, Copy, Paperclip } from "lucide-react"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; +import { AgentIcon } from "./AgentIconPicker"; import { formatDateTime } from "../lib/utils"; interface CommentWithRunMeta extends IssueComment { @@ -91,6 +92,25 @@ function parseReassignment(target: string): CommentReassignment | null { return null; } +function CopyMarkdownButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -159,12 +179,15 @@ const TimelineList = memo(function TimelineList({ ) : ( )} - - {formatDateTime(comment.createdAt)} - + + + {formatDateTime(comment.createdAt)} + + +
{comment.body} {comment.runId && ( @@ -385,6 +408,32 @@ export function CommentThread({ emptyMessage="No assignees found." onChange={setReassignTarget} className="text-xs h-8" + renderTriggerValue={(option) => { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} /> )} )} - {(agents ?? []) - .filter((a) => a.status !== "terminated") + {sortedAgents .filter((a) => { if (!assigneeSearch.trim()) return true; const q = assigneeSearch.toLowerCase(); @@ -356,7 +362,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", a.id === issue.assigneeAgentId && "bg-accent" )} - onClick={() => { onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }} + onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }} > {a.name} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 9ebf80e0..6335f02c 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useDeferredValue, useMemo, useState, useCallback, useRef } from "react"; +import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; @@ -17,7 +17,7 @@ import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclipai/shared"; @@ -142,6 +142,8 @@ interface IssuesListProps { projectId?: string; viewStateKey: string; initialAssignees?: string[]; + initialSearch?: string; + onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -154,6 +156,8 @@ export function IssuesList({ projectId, viewStateKey, initialAssignees, + initialSearch, + onSearchChange, onUpdateIssue, }: IssuesListProps) { const { selectedCompanyId } = useCompany(); @@ -170,9 +174,20 @@ export function IssuesList({ }); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); - const [issueSearch, setIssueSearch] = useState(""); - const deferredIssueSearch = useDeferredValue(issueSearch); - const normalizedIssueSearch = deferredIssueSearch.trim(); + const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); + const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch); + const normalizedIssueSearch = debouncedIssueSearch.trim(); + + useEffect(() => { + setIssueSearch(initialSearch ?? ""); + }, [initialSearch]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedIssueSearch(issueSearch); + }, 300); + return () => window.clearTimeout(timeoutId); + }, [issueSearch]); // Reload view state from localStorage when company changes (scopedKey changes). const prevScopedKey = useRef(scopedKey); @@ -218,6 +233,24 @@ export function IssuesList({ const activeFilterCount = countActiveFilters(viewState); + const [showScrollBottom, setShowScrollBottom] = useState(false); + useEffect(() => { + const el = document.getElementById("main-content"); + if (!el) return; + const check = () => { + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + setShowScrollBottom(distanceFromBottom > 300); + }; + check(); + el.addEventListener("scroll", check, { passive: true }); + return () => el.removeEventListener("scroll", check); + }, [filtered.length]); + + const scrollToBottom = useCallback(() => { + const el = document.getElementById("main-content"); + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }, []); + const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; @@ -273,7 +306,10 @@ export function IssuesList({ setIssueSearch(e.target.value)} + onChange={(e) => { + setIssueSearch(e.target.value); + onSearchChange?.(e.target.value); + }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" @@ -706,6 +742,15 @@ export function IssuesList({ )) )} + {showScrollBottom && ( + + )}
); } diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 253152d0..9d176179 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -26,9 +26,13 @@ interface FeedItem { agentName: string; text: string; tone: FeedTone; + dedupeKey: string; + streamingKind?: "assistant" | "thinking"; } const MAX_FEED_ITEMS = 80; +const MAX_FEED_TEXT_LENGTH = 220; +const MAX_STREAMING_TEXT_LENGTH = 4000; const LOG_POLL_INTERVAL_MS = 2000; const LOG_READ_LIMIT_BYTES = 256_000; @@ -81,17 +85,25 @@ function createFeedItem( text: string, tone: FeedTone, nextId: number, + options?: { + streamingKind?: "assistant" | "thinking"; + preserveWhitespace?: boolean; + }, ): FeedItem | null { - const trimmed = text.trim(); - if (!trimmed) return null; + if (!text.trim()) return null; + const base = options?.preserveWhitespace ? text : text.trim(); + const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; + const normalized = base.length > maxLength ? base.slice(-maxLength) : base; return { id: `${run.id}:${nextId}`, ts, runId: run.id, agentId: run.agentId, agentName: run.agentName, - text: trimmed.slice(0, 220), + text: normalized, tone, + dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, + streamingKind: options?.streamingKind, }; } @@ -108,16 +120,28 @@ function parseStdoutChunk( pendingByRun.set(pendingKey, split.pop() ?? ""); const adapter = getUIAdapter(run.adapterType); - const summarized: Array<{ text: string; tone: FeedTone; thinkingDelta?: boolean }> = []; + const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; const appendSummary = (entry: TranscriptEntry) => { + if (entry.kind === "assistant" && entry.delta) { + const text = entry.text; + if (!text.trim()) return; + const last = summarized[summarized.length - 1]; + if (last && last.streamingKind === "assistant") { + last.text += text; + } else { + summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); + } + return; + } + if (entry.kind === "thinking" && entry.delta) { const text = entry.text; if (!text.trim()) return; const last = summarized[summarized.length - 1]; - if (last && last.thinkingDelta) { + if (last && last.streamingKind === "thinking") { last.text += text; } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", thinkingDelta: true }); + summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); } return; } @@ -133,6 +157,9 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { + if (run.adapterType === "openclaw_gateway") { + continue; + } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); if (fallback) items.push(fallback); continue; @@ -143,7 +170,10 @@ function parseStdoutChunk( } for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++); + const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { + streamingKind: summary.streamingKind, + preserveWhitespace: !!summary.streamingKind, + }); if (item) items.push(item); } @@ -276,18 +306,39 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { const appendItems = (items: FeedItem[]) => { if (items.length === 0) return; setFeed((prev) => { - const deduped: FeedItem[] = []; + const next = [...prev]; for (const item of items) { - const key = `feed:${item.runId}:${item.ts}:${item.tone}:${item.text}`; - if (seenKeysRef.current.has(key)) continue; - seenKeysRef.current.add(key); - deduped.push(item); + if (seenKeysRef.current.has(item.dedupeKey)) continue; + seenKeysRef.current.add(item.dedupeKey); + + const last = next[next.length - 1]; + if ( + item.streamingKind && + last && + last.runId === item.runId && + last.streamingKind === item.streamingKind + ) { + const mergedText = `${last.text}${item.text}`; + const nextText = + mergedText.length > MAX_STREAMING_TEXT_LENGTH + ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) + : mergedText; + next[next.length - 1] = { + ...last, + ts: item.ts, + text: nextText, + dedupeKey: last.dedupeKey, + }; + continue; + } + + next.push(item); } - if (deduped.length === 0) return prev; if (seenKeysRef.current.size > 6000) { seenKeysRef.current.clear(); } - return [...prev, ...deduped].slice(-MAX_FEED_ITEMS); + if (next.length === prev.length) return prev; + return next.slice(-MAX_FEED_ITEMS); }); }; diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c1eb59ac..b996629a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { parseProjectMentionHref } from "@paperclipai/shared"; @@ -10,6 +10,30 @@ interface MarkdownBodyProps { className?: string; } +let mermaidLoaderPromise: Promise | null = null; + +function loadMermaid() { + if (!mermaidLoaderPromise) { + mermaidLoaderPromise = import("mermaid").then((module) => module.default); + } + return mermaidLoaderPromise; +} + +function flattenText(value: ReactNode): string { + if (value == null) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (Array.isArray(value)) return value.map((item) => flattenText(item)).join(""); + return ""; +} + +function extractMermaidSource(children: ReactNode): string | null { + if (!isValidElement(children)) return null; + const childProps = children.props as { className?: unknown; children?: ReactNode }; + if (typeof childProps.className !== "string") return null; + if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null; + return flattenText(childProps.children).replace(/\n$/, ""); +} + function hexToRgb(hex: string): { r: number; g: number; b: number } | null { const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) return null; @@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined { }; } +function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { + const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + setSvg(null); + setError(null); + + loadMermaid() + .then(async (mermaid) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: darkMode ? "dark" : "default", + fontFamily: "inherit", + suppressErrorRendering: true, + }); + const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source); + if (!active) return; + setSvg(rendered.svg); + }) + .catch((err) => { + if (!active) return; + const message = + err instanceof Error && err.message + ? err.message + : "Failed to render Mermaid diagram."; + setError(message); + }); + + return () => { + active = false; + }; + }, [darkMode, renderId, source]); + + return ( +
+ {svg ? ( +
+ ) : ( + <> +

+ {error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."} +

+
+            {source}
+          
+ + )} +
+ ); +} + export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); return ( @@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { { + const mermaidSource = extractMermaidSource(preChildren); + if (mermaidSource) { + return ; + } + return
{preChildren}
; + }, a: ({ href, children: linkChildren }) => { const parsed = href ? parseProjectMentionHref(href) : null; if (parsed) { diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 14a902cb..18830792 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,53 +1,86 @@ -import { useState, useEffect } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState, type ComponentType } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; -import { AGENT_ROLES } from "@paperclipai/shared"; import { Dialog, DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Minimize2, - Maximize2, - Shield, - User, + ArrowLeft, + Bot, + Code, + MousePointer2, + Sparkles, + Terminal, } from "lucide-react"; -import { cn, agentUrl } from "../lib/utils"; -import { roleLabels } from "./agent-config-primitives"; -import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm"; -import { defaultCreateValues } from "./agent-config-defaults"; -import { getUIAdapter } from "../adapters"; -import { AgentIcon } from "./AgentIconPicker"; +import { cn } from "@/lib/utils"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; + +type AdvancedAdapterType = + | "claude_local" + | "codex_local" + | "opencode_local" + | "pi_local" + | "cursor" + | "openclaw_gateway"; + +const ADVANCED_ADAPTER_OPTIONS: Array<{ + value: AdvancedAdapterType; + label: string; + desc: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; +}> = [ + { + value: "claude_local", + label: "Claude Code", + icon: Sparkles, + desc: "Local Claude agent", + recommended: true, + }, + { + value: "codex_local", + label: "Codex", + icon: Code, + desc: "Local Codex agent", + recommended: true, + }, + { + value: "opencode_local", + label: "OpenCode", + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent", + }, + { + value: "pi_local", + label: "Pi", + icon: Terminal, + desc: "Local Pi agent", + }, + { + value: "cursor", + label: "Cursor", + icon: MousePointer2, + desc: "Local Cursor agent", + }, + { + value: "openclaw_gateway", + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol", + }, +]; export function NewAgentDialog() { - const { newAgentOpen, closeNewAgent } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const queryClient = useQueryClient(); + const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); - const [expanded, setExpanded] = useState(true); - - // Identity - const [name, setName] = useState(""); - const [title, setTitle] = useState(""); - const [role, setRole] = useState("general"); - const [reportsTo, setReportsTo] = useState(""); - - // Config values (managed by AgentConfigForm) - const [configValues, setConfigValues] = useState(defaultCreateValues); - - // Popover states - const [roleOpen, setRoleOpen] = useState(false); - const [reportsToOpen, setReportsToOpen] = useState(false); + const [showAdvancedCards, setShowAdvancedCards] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -55,240 +88,127 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(configValues.adapterType), - enabled: newAgentOpen, - }); + const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); - const isFirstAgent = !agents || agents.length === 0; - const effectiveRole = isFirstAgent ? "ceo" : role; - - // Auto-fill for CEO - useEffect(() => { - if (newAgentOpen && isFirstAgent) { - if (!name) setName("CEO"); - if (!title) setTitle("CEO"); - } - }, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps - - const createAgent = useMutation({ - mutationFn: (data: Record) => - agentsApi.hire(selectedCompanyId!, data), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); - reset(); - closeNewAgent(); - navigate(agentUrl(result.agent)); - }, - }); - - function reset() { - setName(""); - setTitle(""); - setRole("general"); - setReportsTo(""); - setConfigValues(defaultCreateValues); - setExpanded(true); - } - - function buildAdapterConfig() { - const adapter = getUIAdapter(configValues.adapterType); - return adapter.buildAdapterConfig(configValues); - } - - function handleSubmit() { - if (!selectedCompanyId || !name.trim()) return; - createAgent.mutate({ - name: name.trim(), - role: effectiveRole, - ...(title.trim() ? { title: title.trim() } : {}), - ...(reportsTo ? { reportsTo } : {}), - adapterType: configValues.adapterType, - adapterConfig: buildAdapterConfig(), - runtimeConfig: { - heartbeat: { - enabled: configValues.heartbeatEnabled, - intervalSec: configValues.intervalSec, - wakeOnDemand: true, - cooldownSec: 10, - maxConcurrentRuns: 1, - }, - }, - budgetMonthlyCents: 0, + function handleAskCeo() { + closeNewAgent(); + openNewIssue({ + assigneeAgentId: ceoAgent?.id, + title: "Create a new agent", + description: "(type in what kind of agent you want here)", }); } - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } + function handleAdvancedConfig() { + setShowAdvancedCards(true); } - const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); + function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { + closeNewAgent(); + setShowAdvancedCards(false); + navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); + } return ( { - if (!open) { reset(); closeNewAgent(); } + if (!open) { + setShowAdvancedCards(false); + closeNewAgent(); + } }} > {/* Header */}
-
- {selectedCompany && ( - - {selectedCompany.name.slice(0, 3).toUpperCase()} - - )} - - New agent -
-
- - -
-
- -
- {/* Name */} -
- setName(e.target.value)} - autoFocus - /> -
- - {/* Title */} -
- setTitle(e.target.value)} - /> -
- - {/* Property chips: Role + Reports To */} -
- {/* Role */} - - - - - - {AGENT_ROLES.map((r) => ( - - ))} - - - - {/* Reports To */} - - - - - - - {(agents ?? []).map((a) => ( - - ))} - - -
- - {/* Shared config form (adapter + heartbeat) */} - setConfigValues((prev) => ({ ...prev, ...patch }))} - adapterModels={adapterModels} - /> -
- - {/* Footer */} -
- - {isFirstAgent ? "This will be the CEO" : ""} - + Add a new agent
+ +
+ {!showAdvancedCards ? ( + <> + {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
+ + + + {/* Advanced link */} +
+ +
+ + ) : ( + <> +
+ +

+ Choose your adapter type for advanced setup. +

+
+ +
+ {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + + ))} +
+ + )} +
); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 1840c727..d85a2291 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { useToast } from "../context/ToastContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; @@ -10,6 +9,7 @@ import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; +import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { Dialog, DialogContent, @@ -36,6 +36,7 @@ import { Paperclip, } from "lucide-react"; import { cn } from "../lib/utils"; +import { extractProviderIdWithFallback } from "../lib/model-utils"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; @@ -116,6 +117,8 @@ function buildAssigneeAdapterOverrides(input: { adapterConfig.variant = input.thinkingEffortOverride; } else if (adapterType === "claude_local") { adapterConfig.effort = input.thinkingEffortOverride; + } else if (adapterType === "opencode_local") { + adapterConfig.variant = input.thinkingEffortOverride; } } if (adapterType === "claude_local" && input.chrome) { @@ -168,7 +171,6 @@ const priorities = [ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { companies, selectedCompanyId, selectedCompany } = useCompany(); - const { pushToast } = useToast(); const queryClient = useQueryClient(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -249,27 +251,23 @@ export function NewIssueDialog() { }, [agents, orderedProjects]); const { data: assigneeAdapterModels } = useQuery({ - queryKey: ["adapter-models", assigneeAdapterType], - queryFn: () => agentsApi.adapterModels(assigneeAdapterType!), - enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides, + queryKey: + effectiveCompanyId && assigneeAdapterType + ? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType) + : ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"], + queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!), + enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides, }); const createIssue = useMutation({ mutationFn: ({ companyId, ...data }: { companyId: string } & Record) => issuesApi.create(companyId, data), - onSuccess: (issue) => { + onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) }); if (draftTimer.current) clearTimeout(draftTimer.current); clearDraft(); reset(); closeNewIssue(); - pushToast({ - dedupeKey: `activity:issue.created:${issue.id}`, - title: `${issue.identifier ?? "Issue"} created`, - body: issue.title, - tone: "success", - action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` }, - }); }, }); @@ -327,7 +325,18 @@ export function NewIssueDialog() { setDialogCompanyId(selectedCompanyId); const draft = loadDraft(); - if (draft && draft.title.trim()) { + if (newIssueDefaults.title) { + setTitle(newIssueDefaults.title); + setDescription(newIssueDefaults.description ?? ""); + setStatus(newIssueDefaults.status ?? "todo"); + setPriority(newIssueDefaults.priority ?? ""); + setProjectId(newIssueDefaults.projectId ?? ""); + setAssigneeId(newIssueDefaults.assigneeAgentId ?? ""); + setAssigneeModelOverride(""); + setAssigneeThinkingEffort(""); + setAssigneeChrome(false); + setAssigneeUseProjectWorkspace(true); + } else if (draft && draft.title.trim()) { setTitle(draft.title); setDescription(draft.description); setStatus(draft.status || "todo"); @@ -365,7 +374,7 @@ export function NewIssueDialog() { ? ISSUE_THINKING_EFFORT_OPTIONS.codex_local : assigneeAdapterType === "opencode_local" ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local - : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; + : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) { setAssigneeThinkingEffort(""); } @@ -474,16 +483,18 @@ export function NewIssueDialog() { : assigneeAdapterType === "opencode_local" ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; + const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( () => - (agents ?? []) - .filter((agent) => agent.status !== "terminated") - .map((agent) => ({ - id: agent.id, - label: agent.name, - searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, - })), - [agents], + sortAgentsByRecency( + (agents ?? []).filter((agent) => agent.status !== "terminated"), + recentAssigneeIds, + ).map((agent) => ({ + id: agent.id, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })), + [agents, recentAssigneeIds], ); const projectOptions = useMemo( () => @@ -495,12 +506,21 @@ export function NewIssueDialog() { [orderedProjects], ); const modelOverrideOptions = useMemo( - () => - (assigneeAdapterModels ?? []).map((model) => ({ - id: model.id, - label: model.label, - searchText: model.id, - })), + () => { + return [...(assigneeAdapterModels ?? [])] + .sort((a, b) => { + const providerA = extractProviderIdWithFallback(a.id); + const providerB = extractProviderIdWithFallback(b.id); + const byProvider = providerA.localeCompare(providerB); + if (byProvider !== 0) return byProvider; + return a.id.localeCompare(b.id); + }) + .map((model) => ({ + id: model.id, + label: model.label, + searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, + })); + }, [assigneeAdapterModels], ); @@ -521,6 +541,18 @@ export function NewIssueDialog() { : "sm:max-w-lg" )} onKeyDown={handleKeyDown} + onPointerDownOutside={(event) => { + // Radix Dialog's modal DismissableLayer calls preventDefault() on + // pointerdown events that originate outside the Dialog DOM tree. + // Popover portals render at the body level (outside the Dialog), so + // touch events on popover content get their default prevented — which + // kills scroll gesture recognition on mobile. Telling Radix "this + // event is handled" skips that preventDefault, restoring touch scroll. + const target = event.detail.originalEvent.target as HTMLElement | null; + if (target?.closest("[data-radix-popper-content-wrapper]")) { + event.preventDefault(); + } + }} > {/* Header bar */}
@@ -628,18 +660,19 @@ export function NewIssueDialog() {
-
-
+
+
For { if (id) trackRecentAssignee(id); setAssigneeId(id); }} onConfirm={() => { projectSelectorRef.current?.focus(); }} @@ -670,6 +703,7 @@ export function NewIssueDialog() { value={projectId} options={projectOptions} placeholder="Project" + disablePortal noneLabel="No project" searchPlaceholder="Search projects..." emptyMessage="No projects found." @@ -725,6 +759,7 @@ export function NewIssueDialog() { value={assigneeModelOverride} options={modelOverrideOptions} placeholder="Default model" + disablePortal noneLabel="Default model" searchPlaceholder="Search models..." emptyMessage="No models found." diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 61541e3c..fbcbc7bf 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useCallback } from "react"; +import { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; @@ -17,6 +17,7 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; +import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; import { @@ -24,10 +25,10 @@ import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; -import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { Building2, Bot, @@ -37,7 +38,6 @@ import { ArrowLeft, ArrowRight, Terminal, - Globe, Sparkles, MousePointer2, Check, @@ -52,10 +52,11 @@ type AdapterType = | "claude_local" | "codex_local" | "opencode_local" + | "pi_local" | "cursor" | "process" | "http" - | "openclaw"; + | "openclaw_gateway"; const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md) @@ -76,6 +77,7 @@ export function OnboardingWizard() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [modelOpen, setModelOpen] = useState(false); + const [modelSearch, setModelSearch] = useState(""); // Step 1 const [companyName, setCompanyName] = useState(""); @@ -149,10 +151,18 @@ export function OnboardingWizard() { if (step === 3) autoResizeTextarea(); }, [step, taskDescription, autoResizeTextarea]); - const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(adapterType), - enabled: onboardingOpen && step === 2 + const { + data: adapterModels, + error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, + } = useQuery({ + queryKey: + createdCompanyId + ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) + : ["agents", "none", "adapter-models", adapterType], + queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), + enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 }); const isLocalAdapter = adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor"; @@ -162,9 +172,9 @@ export function OnboardingWizard() { ? "codex" : adapterType === "cursor" ? "agent" - : adapterType === "opencode_local" - ? "opencode" - : "claude"); + : adapterType === "opencode_local" + ? "opencode" + : "claude"); useEffect(() => { if (step !== 2) return; @@ -182,6 +192,41 @@ export function OnboardingWizard() { adapterType === "claude_local" && adapterEnvResult?.status === "fail" && hasAnthropicApiKeyOverrideCheck; + const filteredModels = useMemo(() => { + const query = modelSearch.trim().toLowerCase(); + return (adapterModels ?? []).filter((entry) => { + if (!query) return true; + const provider = extractProviderIdWithFallback(entry.id, ""); + return ( + entry.id.toLowerCase().includes(query) || + entry.label.toLowerCase().includes(query) || + provider.toLowerCase().includes(query) + ); + }); + }, [adapterModels, modelSearch]); + const groupedModels = useMemo(() => { + if (adapterType !== "opencode_local") { + return [ + { + provider: "models", + entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), + }, + ]; + } + const groups = new Map>(); + for (const entry of filteredModels) { + const provider = extractProviderIdWithFallback(entry.id); + const bucket = groups.get(provider) ?? []; + bucket.push(entry); + groups.set(provider, bucket); + } + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, entries]) => ({ + provider, + entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), + })); + }, [filteredModels, adapterType]); function reset() { setStep(1); @@ -225,8 +270,6 @@ export function OnboardingWizard() { ? model || DEFAULT_CODEX_LOCAL_MODEL : adapterType === "cursor" ? model || DEFAULT_CURSOR_LOCAL_MODEL - : adapterType === "opencode_local" - ? model || DEFAULT_OPENCODE_LOCAL_MODEL : model, command, args, @@ -315,6 +358,35 @@ export function OnboardingWizard() { setLoading(true); setError(null); try { + if (adapterType === "opencode_local") { + const selectedModelId = model.trim(); + if (!selectedModelId) { + setError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + if (adapterModelsLoading || adapterModelsFetching) { + setError("OpenCode models are still loading. Please wait and try again."); + return; + } + const discoveredModels = adapterModels ?? []; + if (!discoveredModels.some((entry) => entry.id === selectedModelId)) { + setError( + discoveredModels.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModelId}`, + ); + return; + } + } + if (isLocalAdapter) { const result = adapterEnvResult ?? (await runAdapterEnvironmentTest()); if (!result) return; @@ -590,35 +662,28 @@ export function OnboardingWizard() { { value: "opencode_local" as const, label: "OpenCode", - icon: Code, - desc: "Local OpenCode agent" + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent" }, { - value: "openclaw" as const, - label: "OpenClaw", + value: "pi_local" as const, + label: "Pi", + icon: Terminal, + desc: "Local Pi agent" + }, + { + value: "openclaw_gateway" as const, + label: "OpenClaw Gateway", icon: Bot, - desc: "Notify OpenClaw webhook", - comingSoon: true + desc: "Invoke OpenClaw via gateway protocol", + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App" }, { value: "cursor" as const, label: "Cursor", icon: MousePointer2, desc: "Local Cursor agent" - }, - { - value: "process" as const, - label: "Shell Command", - icon: Terminal, - desc: "Run a process", - comingSoon: true - }, - { - value: "http" as const, - label: "HTTP Webhook", - icon: Globe, - desc: "Call an endpoint", - comingSoon: true } ].map((opt) => ( ))} @@ -664,6 +737,7 @@ export function OnboardingWizard() { {(adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || + adapterType === "pi_local" || adapterType === "cursor") && (
@@ -688,7 +762,13 @@ export function OnboardingWizard() { - + { + setModelOpen(next); + if (!next) setModelSearch(""); + }} + > @@ -707,36 +790,60 @@ export function OnboardingWizard() { className="w-[var(--radix-popover-trigger-width)] p-1" align="start" > - - {(adapterModels ?? []).map((m) => ( + setModelSearch(e.target.value)} + autoFocus + /> + {adapterType !== "opencode_local" && ( - ))} + Default + + )} +
+ {groupedModels.map((group) => ( +
+ {adapterType === "opencode_local" && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
+ ))} +
+ {filteredModels.length === 0 && ( +

+ No models discovered. +

+ )}
@@ -802,7 +909,7 @@ export function OnboardingWizard() { : adapterType === "codex_local" ? `${effectiveAdapterCommand} exec --json -` : adapterType === "opencode_local" - ? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"` + ? `${effectiveAdapterCommand} run --format json "Respond with hello."` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}

@@ -863,14 +970,14 @@ export function OnboardingWizard() {

)} - {(adapterType === "http" || adapterType === "openclaw") && ( + {(adapterType === "http" || adapterType === "openclaw_gateway") && (
setUrl(e.target.value)} /> diff --git a/ui/src/components/OpenCodeLogoIcon.tsx b/ui/src/components/OpenCodeLogoIcon.tsx new file mode 100644 index 00000000..fb3c1d78 --- /dev/null +++ b/ui/src/components/OpenCodeLogoIcon.tsx @@ -0,0 +1,22 @@ +import { cn } from "../lib/utils"; + +interface OpenCodeLogoIconProps { + className?: string; +} + +export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) { + return ( + <> + OpenCode + OpenCode + + ); +} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 277ee687..ea95968e 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -3,11 +3,12 @@ import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import { StatusBadge } from "./StatusBadge"; -import { formatDate } from "../lib/utils"; +import { cn, formatDate } from "../lib/utils"; import { goalsApi } from "../api/goals"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { statusBadge, statusBadgeDefault } from "../lib/status-colors"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -15,6 +16,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; +const PROJECT_STATUSES = [ + { value: "backlog", label: "Backlog" }, + { value: "planned", label: "Planned" }, + { value: "in_progress", label: "In Progress" }, + { value: "completed", label: "Completed" }, + { value: "cancelled", label: "Cancelled" }, +]; + interface ProjectPropertiesProps { project: Project; onUpdate?: (data: Record) => void; @@ -31,6 +40,42 @@ function PropertyRow({ label, children }: { label: string; children: React.React ); } +function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (status: string) => void }) { + const [open, setOpen] = useState(false); + const colorClass = statusBadge[status] ?? statusBadgeDefault; + + return ( + + + + + + {PROJECT_STATUSES.map((s) => ( + + ))} + + + ); +} + export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -212,7 +257,14 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
- + {onUpdate ? ( + onUpdate({ status })} + /> + ) : ( + + )} {project.leadAgentId && ( diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index c1b57f7e..ae5e83d4 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -65,7 +65,7 @@ export function Sidebar() {
-
diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 5dab7744..8e5c7050 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -15,6 +15,7 @@ import { import { Button } from "@/components/ui/button"; import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "../lib/utils"; +import { AGENT_ROLE_LABELS } from "@paperclipai/shared"; /* ---- Help text for (?) tooltips ---- */ export const help: Record = { @@ -23,7 +24,7 @@ export const help: Record = { role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", - adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", @@ -34,7 +35,7 @@ export const help: Record = { search: "Enable Codex web search capability during runs.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", command: "The command to execute (e.g. node, python).", - localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).", + localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).", args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", @@ -53,17 +54,13 @@ export const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", - openclaw: "OpenClaw", + openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", http: "HTTP", }; -export const roleLabels: Record = { - ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", - engineer: "Engineer", designer: "Designer", pm: "PM", - qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", -}; +export const roleLabels = AGENT_ROLE_LABELS as Record; /* ---- Primitive components ---- */ diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx index 8cb4ca7a..bd2ee877 100644 --- a/ui/src/components/ui/command.tsx +++ b/ui/src/components/ui/command.tsx @@ -2,7 +2,8 @@ import * as React from "react" import { Command as CommandPrimitive } from "cmdk" -import { SearchIcon } from "lucide-react" +import { SearchIcon, XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { @@ -50,11 +51,20 @@ function CommandDialog({ {children} + {showCloseButton && ( + + + Close + + )} ) diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 103bec3e..c250a7d9 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -19,22 +19,23 @@ function PopoverContent({ className, align = "center", sideOffset = 4, + disablePortal = false, ...props -}: React.ComponentProps) { - return ( - - - +}: React.ComponentProps & { disablePortal?: boolean }) { + const content = ( + ) + if (disablePortal) return content + return {content} } function PopoverAnchor({ diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index b21e6b8a..ef7b12b8 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,8 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + title?: string; + description?: string; } interface NewGoalDefaults { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 34ff5c0a..25d0381e 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, type ReactNode } from "react"; -import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { Agent, Issue, LiveEvent } from "@paperclipai/shared"; +import { authApi } from "../api/auth"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToast } from "./ToastContext"; @@ -152,6 +153,7 @@ function buildActivityToast( queryClient: QueryClient, companyId: string, payload: Record, + currentActor: { userId: string | null; agentId: string | null }, ): ToastInput | null { const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); @@ -166,6 +168,10 @@ function buildActivityToast( const issue = resolveIssueToastContext(queryClient, companyId, entityId, details); const actor = resolveActorLabel(queryClient, companyId, actorType, actorId); + const isSelfActivity = + (actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) || + (actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId); + if (isSelfActivity) return null; if (action === "issue.created") { return { @@ -178,8 +184,8 @@ function buildActivityToast( } if (action === "issue.updated") { - if (details?.reopened === true && readString(details.source) === "comment") { - // Reopen-via-comment emits a paired comment event; show one combined toast on the comment event. + if (readString(details?.source) === "comment") { + // Comment-driven updates emit a paired comment event; show one combined toast on the comment event. return null; } const changeDesc = describeIssueUpdate(details); @@ -202,13 +208,18 @@ function buildActivityToast( const commentId = readString(details?.commentId); const bodySnippet = readString(details?.bodySnippet); const reopened = details?.reopened === true; + const updated = details?.updated === true; const reopenedFrom = readString(details?.reopenedFrom); const reopenedLabel = reopened ? reopenedFrom ? `reopened from ${reopenedFrom.replace(/_/g, " ")}` : "reopened" : null; - const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`; + const title = reopened + ? `${actor} reopened and commented on ${issue.ref}` + : updated + ? `${actor} commented and updated ${issue.ref}` + : `${actor} commented on ${issue.ref}`; const body = bodySnippet ? reopenedLabel ? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}` @@ -227,6 +238,29 @@ function buildActivityToast( }; } +function buildJoinRequestToast( + payload: Record, +): ToastInput | null { + const entityType = readString(payload.entityType); + const action = readString(payload.action); + const entityId = readString(payload.entityId); + const details = readRecord(payload.details); + + if (entityType !== "join_request" || !action || !entityId) return null; + if (action !== "join.requested" && action !== "join.request_replayed") return null; + + const requestType = readString(details?.requestType); + const label = requestType === "agent" ? "Agent" : "Someone"; + + return { + title: `${label} wants to join`, + body: "A new join request is waiting for approval.", + tone: "info", + action: { label: "View inbox", href: "/inbox/new" }, + dedupeKey: `join-request:${entityId}`, + }; +} + function buildAgentStatusToast( payload: Record, nameOf: (id: string) => string | null, @@ -369,6 +403,11 @@ function invalidateActivityQueries( return; } + if (entityType === "join_request") { + queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) }); + return; + } + if (entityType === "cost_event") { queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); return; @@ -420,6 +459,7 @@ function handleLiveEvent( event: LiveEvent, pushToast: (toast: ToastInput) => string | null, gate: ToastGate, + currentActor: { userId: string | null; agentId: string | null }, ) { if (event.companyId !== expectedCompanyId) return; @@ -456,7 +496,9 @@ function handleLiveEvent( if (event.type === "activity.logged") { invalidateActivityQueries(queryClient, expectedCompanyId, payload); const action = readString(payload.action); - const toast = buildActivityToast(queryClient, expectedCompanyId, payload); + const toast = + buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ?? + buildJoinRequestToast(payload); if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast); } } @@ -466,6 +508,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; useEffect(() => { if (!selectedCompanyId) return; @@ -511,7 +559,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current); + handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { + userId: currentUserId, + agentId: null, + }); } catch { // Ignore non-JSON payloads. } @@ -540,7 +591,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId, pushToast]); + }, [queryClient, selectedCompanyId, pushToast, currentUserId]); return <>{children}; } diff --git a/ui/src/index.css b/ui/src/index.css index cacbdb18..63a8c3dc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -94,8 +94,8 @@ --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); + --destructive: oklch(0.637 0.237 25.331); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.269 0 0); --input: oklch(0.269 0 0); --ring: oklch(0.439 0 0); @@ -178,6 +178,17 @@ background: oklch(0.5 0 0); } +/* Auto-hide scrollbar: transparent by default, visible on container hover */ +.scrollbar-auto-hide::-webkit-scrollbar-thumb { + background: transparent !important; +} +.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb { + background: oklch(0.4 0 0) !important; +} +.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0 0) !important; +} + /* Expandable dialog transition for max-width changes */ [data-slot="dialog-content"] { transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1); @@ -415,6 +426,40 @@ font-weight: 500; } +.paperclip-mermaid { + margin: 0.5rem 0; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 3px); + background-color: color-mix(in oklab, var(--accent) 35%, transparent); + overflow-x: auto; +} + +.paperclip-mermaid svg { + display: block; + width: max-content; + max-width: none; + min-width: 100%; + height: auto; +} + +.paperclip-mermaid-status { + margin: 0 0 0.45rem; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.paperclip-mermaid-status-error { + color: var(--destructive); +} + +.paperclip-mermaid-source { + margin: 0; + padding: 0; + border: 0; + background: transparent; +} + /* Project mention chips rendered inside MarkdownBody */ a.paperclip-project-mention-chip { display: inline-flex; diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts new file mode 100644 index 00000000..baa721f5 --- /dev/null +++ b/ui/src/lib/model-utils.ts @@ -0,0 +1,16 @@ +export function extractProviderId(modelId: string): string | null { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return null; + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + return provider || null; +} + +export function extractProviderIdWithFallback(modelId: string, fallback = "other"): string { + return extractProviderId(modelId) ?? fallback; +} + +export function extractModelName(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return trimmed; + return trimmed.slice(trimmed.indexOf("/") + 1).trim(); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1a7722d3..34791488 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -11,12 +11,16 @@ export const queryKeys = { taskSessions: (id: string) => ["agents", "task-sessions", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, + adapterModels: (companyId: string, adapterType: string) => + ["agents", companyId, "adapter-models", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, search: (companyId: string, q: string, projectId?: string) => ["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const, listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const, + listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const, + listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const, labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, diff --git a/ui/src/lib/recent-assignees.ts b/ui/src/lib/recent-assignees.ts new file mode 100644 index 00000000..7c3e9c91 --- /dev/null +++ b/ui/src/lib/recent-assignees.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "paperclip:recent-assignees"; +const MAX_RECENT = 10; + +export function getRecentAssigneeIds(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function trackRecentAssignee(agentId: string): void { + if (!agentId) return; + const recent = getRecentAssigneeIds().filter((id) => id !== agentId); + recent.unshift(agentId); + if (recent.length > MAX_RECENT) recent.length = MAX_RECENT; + localStorage.setItem(STORAGE_KEY, JSON.stringify(recent)); +} + +export function sortAgentsByRecency( + agents: T[], + recentIds: string[], +): T[] { + const recentIndex = new Map(recentIds.map((id, i) => [id, i])); + return [...agents].sort((a, b) => { + const aRecent = recentIndex.get(a.id); + const bRecent = recentIndex.get(b.id); + if (aRecent !== undefined && bRecent !== undefined) return aRecent - bRecent; + if (aRecent !== undefined) return -1; + if (bRecent !== undefined) return 1; + return a.name.localeCompare(b.name); + }); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index ba8a4d69..33fd6c81 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -15,6 +15,12 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import "@mdxeditor/editor/style.css"; import "./index.css"; +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js"); + }); +} + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7c8c6b19..06c3a2f4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; +import { ApiError } from "../api/client"; import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; import { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; @@ -55,7 +56,7 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; -import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState } from "@paperclipai/shared"; +import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared"; import { agentRouteRef } from "../lib/utils"; const runStatusIcons: Record = { @@ -264,11 +265,12 @@ export function AgentDetail() { const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; const agentLookupRef = agent?.id ?? routeAgentRef; + const resolvedAgentId = agent?.id ?? null; const { data: runtimeState } = useQuery({ - queryKey: queryKeys.agents.runtimeState(agentLookupRef), - queryFn: () => agentsApi.runtimeState(agentLookupRef, resolvedCompanyId ?? undefined), - enabled: Boolean(agentLookupRef), + queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), + queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), + enabled: Boolean(resolvedAgentId), }); const { data: heartbeats } = useQuery({ @@ -466,7 +468,7 @@ export function AgentDetail() { disabled={agentAction.isPending || isPendingApproval} > - Invoke + Run Heartbeat {agent.status === "paused" ? (
- +
- Generate a link to invite humans or agents to this company. - + + Generate an OpenClaw agent invite snippet. + +
- - {inviteLink && ( - - )}
- {inviteError &&

{inviteError}

} - {inviteLink && ( + {inviteError && ( +

{inviteError}

+ )} + {inviteSnippet && (
-
Share link
-
{inviteLink}
+
+
+ OpenClaw Invite Prompt +
+ {snippetCopied && ( + + + Copied + + )} +
+
+